From ec47f2e40fdfbff3809ad1db874f9d5df9db33c4 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:01:43 -0700 Subject: [PATCH 001/113] =?UTF-8?q?docs(i18n):=20core-docs=20batch=20?= =?UTF-8?q?=E2=80=94=20five=20bilingual=20pairs=20via=20the=20committed=20?= =?UTF-8?q?pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit architecture / cordis-primer / defensive-patterns / glossary / testing 五篇核心文档配对,译文由进仓流水线产出(committed renderer + 金标 few-shot + 严格 XML 协议),并经二遍校验(逐句对照原文复述核查 + 注入 architecture/glossary 仓库上下文的一致性修复)。五篇生成文档 (agent-lifecycle、capability-seams、event-producer-consumer、 graph-atlas、tool-execution-pipeline 均为 gen-doc-graphs 产物)列入 排除清单——手写译文会在再生成时失效,与既有生成目录排除策略一致。 --- docs/architecture.i18n.yaml | 6 + docs/architecture.md | 2 + docs/architecture.zh.md | 171 ++++++++++++++++++++++ docs/cordis-primer.i18n.yaml | 6 + docs/cordis-primer.md | 2 + docs/cordis-primer.zh.md | 44 ++++++ docs/defensive-patterns.i18n.yaml | 6 + docs/defensive-patterns.md | 2 + docs/defensive-patterns.zh.md | 29 ++++ docs/glossary.i18n.yaml | 6 + docs/glossary.md | 2 + docs/glossary.zh.md | 19 +++ docs/testing.i18n.yaml | 6 + docs/testing.md | 2 + docs/testing.zh.md | 35 +++++ scripts/translation-pairing.manifest.json | 10 ++ 16 files changed, 348 insertions(+) create mode 100644 docs/architecture.i18n.yaml create mode 100644 docs/architecture.zh.md create mode 100644 docs/cordis-primer.i18n.yaml create mode 100644 docs/cordis-primer.zh.md create mode 100644 docs/defensive-patterns.i18n.yaml create mode 100644 docs/defensive-patterns.zh.md create mode 100644 docs/glossary.i18n.yaml create mode 100644 docs/glossary.zh.md create mode 100644 docs/testing.i18n.yaml create mode 100644 docs/testing.zh.md diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml new file mode 100644 index 0000000000..9ff56c00ef --- /dev/null +++ b/docs/architecture.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +architecture.md: 32b9700b9aece988985ff932e597b537872f12c3 +architecture.zh.md: 1adb0111fb67c6e252153e2500732a320de44523 diff --git a/docs/architecture.md b/docs/architecture.md index 4049049e25..32b9700b9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # DeepSeek Harness Architecture +English | [中文](architecture.zh.md) + The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel. ## Overview diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md new file mode 100644 index 0000000000..1adb0111fb --- /dev/null +++ b/docs/architecture.zh.md @@ -0,0 +1,171 @@ +# DeepSeek Harness 架构 + +[English](architecture.md) | 中文 + +**DeepSeek Harness SDK** 基于 Cordis 构建 agent harness(智能体框架)。原则很简单:**一切皆插件**。内置的循环只是一个插件,不是特权内核。 + +## 概览 + +一个 harness 就是一个 [Cordis](cordis-primer.md) 上下文。各包(package)贡献服务键、类型化事件和可 dispose(资源释放)的注册:服务暴露稳定的调用(`ctx.llm`、`ctx.tools`、`ctx.sessions`),事件提供拦截与通知(`agent/request`、`tools/pre-execute`、`session/event`),注册则安装提示词段、工具、提供方、适配器或监听器。 + +`packages/core/` 组织了默认的 agent 流程;周边能力同样是一等的 Cordis 插件。 + +### 默认服务 + +| ctx 键 | 包 | 职责 | +|---|---|---| +| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册原语(库) | +| `ctx.sessions` | `dsh-session` | 内存中事件溯源的会话 | +| `ctx.systemPrompt` | `dsh-system-prompt` | 有序提示词段、工具 schema 与提示词变量 | +| `ctx.tools` | `dsh-tools` | 工具注册表与[执行流水线](tool-execution-pipeline.md) | +| `ctx.agents` | `dsh-agent` | 活跃 agent 注册表、公开 `Agent` 句柄、`agent/*` 事件 | +| `ctx.agentLoop` | `dsh-agent-loop` | 内置 `ReactLoopAgent` 驱动器 | + +### 能力服务 + +| ctx 键 | 包族 | 职责 | +|---|---|---| +| `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表与流式模型调用 | +| `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台/后台命令执行 | +| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同世界进程隔离(argv 包装、逐调用策略) | +| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | 模型编写的程序执行 | +| `ctx.fs` | [`fs/`](../packages/fs/README.md) | 文件系统提供方原语与策略事件 | +| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表与渐进式披露 | +| `ctx.web` | [`web/`](../packages/web/README.md) | 搜索/抓取提供方注册表 | +| `ctx.compact` | [`compact/`](../packages/compact/README.md) | 会话日志压缩(compaction) | +| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 命名委托提供方 | +| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | +| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 活跃优先的逻辑语料库与精确事件读取 | + +## 事件 + +事件构成服务扩展 API;详见完整的[事件目录](cordis-catalog/events.md)与[生产者/消费方映射](event-producer-consumer.md)。 + +### 事件域 + +- **会话事件**是持久的、可回放的事实。轮次与步骤边界、用户输入、助手输出、工具调用、工具结果、steering(中途引导)、压缩记录以及工具拥有的持久事实追加到会话日志,并流经 `session/event`。 +- **Agent 事件**携带活跃的 `Agent` 句柄,用于状态、诊断、prompt 准入、调用配置塑形、结果校验与续行策略。 +- **能力事件**归属于拥有该动作的 seam。`tools/*`、`llm/*`、`system-prompt/*`、`fs/*` 与 `subagent/*` 让策略和适配器无需导入循环即可接入。 + +### 拦截语义 + +waterfall(瀑布式事件)的行为类似 around 中间件:监听器通过调用 `next()` 委托下游;不调用 `next()` 直接返回即为否决或接管。完整规则见 [Cordis waterfall 语义](cordis-primer.md#cordis-waterfall-semantics)。 + +## 默认循环生命周期 + +内置循环消耗工作队列、组装请求、流式接收模型回答、执行工具、应用续行策略并持久化检查点。每一个暂停点都是一个服务调用或事件,可供插件介入。 + +**会话**是一个 agent 的仅追加事件日志。**轮次(turn)**消耗一批排队消息,运行到模型不再请求工具且没有插件要求续行为止。**步骤(step)**是一次模型请求加上该响应引发的工具执行。下面的流程中([时序图伴侣文档](agent-lifecycle.md)),带引号的名称是持久化的会话事件,事件名称是扩展点。 + +### 轮次流程 + +```text +prepare private session + agent.ctx -> await unpublished setup + -> enter session + agent -> session/created -> agent/created + -> enable driving -> agent/session-start(source) -> start driver +forever: + wait for queued messages + emit agent/status(running) + TURN: + 'turn/start' + each queued message -> agent/prompt-submit + allowed prompt -> 'user/message' plus injected context + every prompt blocked -> 'turn/end'(rejected) + STEP loop: + drain steering + assemble system prompt and tool schemas + agent/session-prefix (first step) + agent/pre-step + 'step/start' + snapshot the derived messages (the reconstruction boundary) + agent/request (config only) -> log request/header -> llm/stream (frozen) + 'assistant/chunk' + agent/step-result + 'assistant/message' + each tool call: + 'tool/call' + tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result + 'tool/result' + append post-tool context and steering + 'step/end' + agent/turn-continuation + agent/turn-stop (terminal policy) + stop unless tools or continuation policy ask for another step + 'turn/end' + checkpoint persistence and notify idle/running status +``` + +循环每步骤渲染一次 prompt 组装。插件贡献有序段、工具 schema 与 `{{name}}` 变量;未知或无值的引用会使轮次失败,而非带着空洞发送。`dsh-system-prompt` 拥有 harness 身份与默认部署人格;agent 作用域的人格可以遮蔽默认值。循环提供 `model` 和 `cwd`。见 [prompt 所有权 RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 + +Post-tool 上下文在所有工具结果之后落入,以保持 tool-call/result 的邻接稳定。steering 在步骤之间排空;轮次结束后的普通剩余 steering 作为输入重新入队。终止性的 `agent/turn-stop` 是显式例外:它在普通续行与 steering 折叠之后运行,然后在轮次关闭和刷新期间保持权威,因此这些后续监听器产生的 steering 被丢弃而非成为新的步骤或轮次;普通排队的 prompt 则被保留。 + +### 失败边界 + +轮次是容错边界。抛出异常的监听器、适配器错误结束或失败的步骤会以错误原因结束当前轮次,并通过 `agent/error` 报告实时诊断;它不会杀死驱动循环。`cancel()` 清除排队与 steering 工作,在可能时中止活跃的模型/工具边界,并记录相应的轮次结束。dispose 停止循环、等待静默、注销 agent,并让服务 disposer 排空。 + +每个会话事件都被轮次包围。重新加载崩溃的会话时,系统保留中断的尾部并以合成的 `interrupted` 轮次结束关闭它。持久化轮次已关闭后的失败仅通过 `agent/error` 报告,因为已没有安全的轮次内位置。轮次以一个 `TurnEndReason` 结束(`completed`、`aborted`、`error`、`disposed`、`max-tokens`、`rejected` 或 `interrupted`);各变体的语义见 [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 + +### Agent 句柄 + +`ctx.agents` 拥有活跃 agent 并返回 `AgentHandle { agent, dispose() }`。`Agent` 是其他插件驱动的 API:`send()` 入队工作,`steer()` 注入轮次中内容,`inject()` 追加上下文并在空闲时开启一次性注入轮次,`cancel()` 是公开的停止原语,`whenIdle()` 观察静默状态。调用方 fiber 与具体工厂提供方在结构上共同拥有编程式生命周期;消费方句柄是唯一的非结构性拆卸能力,且每个所有者到达同一个被 await 的 disposer。 + +### Agent 作用域 + +每个活跃 agent 拥有一个作用域化的 `agent.ctx`。其注册遮蔽同名全局注册,只接收该 agent 的派发,并随 agent 一起解除。`CreateAgentOptions.setup(agentCtx)` 在发布前组合作用域。[语义门禁 RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) 定义了类型化解析器,从合并的 `Events` 签名与 `scopeTarget` 派生载体检查,消除了手写事件表。见 [agent 作用域 RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md);subagent 组合控制另行记录于[此](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 + +## 状态 + +### 会话日志 + +会话日志是真源。`deriveMessages()` 将会话事件投影为发送给模型的 `Message[]`;原始 `assistant/chunk` 事件留在日志中用于回放和 UI 保真。回放、fork、恢复、transcript(文本记录)渲染、遥测和持久化都从同一事件流派生。 + +**模型可见 ⟺ 已记录**:日志能重建每次请求——`step/start` 处的消息前置 header 的会话前缀,header 通过折叠 `request/header` 得出——开发不变式对此做断言([可重建性 RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md))。 + +持久性是插件关注点。持久化后端缓冲同步的 `session/event` 通知,循环在轮次结束检查点完成后才继续。`SessionPersistence` seam 直接存储 `SessionEvent`,元数据在 `SessionHeader` 中;JSONL 与 SQLite 共享同一套契约测试。 + +### 模型内容 + +消息是类型化内容块(`text`、`reasoning`、`tool-call`、`tool-result`)的数组。联合类型派生自可合并扩展的 `ContentBlockMap`;同一模式也用于 `MessageSource`、`FinishReason`、`TurnTrigger` 与 `TurnEndReason`。新的块类型需要跨适配器、UI 桥接、压缩计价与持久化协调,因此块类型仍是仓库级契约。 + +流式输出是原始分片协议(从 `block-start` 到 `finish`),`BlockAssembler` 是共享的 chunk 到 block 组装器。循环在组装分片以供派发的同时记录原始 chunk。`LlmAdapter` 是提供方 seam:继承它、实现 `stream()`、用 `ctx.llm.registerAdapter(models, adapter)` 注册。StreamChunk 约定见 [llm-streaming.md](core-data-structures/llm-streaming.md)。 + +## 扩展与组合 + +### 能力模式 + +一个可替换的能力通常拆分为**接口 / 实现 / 消费方**:接口拥有其 `ctx` 键与事件,实现注册后端,消费方通过工具或 prompt 暴露模型行为。Bash 是参考实现;[能力图](capability-seams.md)展示了每个族。 + +部分 seam 有意偏离模板。LLM(大语言模型)将接口与消费方词汇放在一起,因为适配器就是实现。文件系统在提供方原语周围添加策略门禁。Web 是一个服务加搜索/抓取两个提供方注册表,因此提供方替换不会重命名模型工具。skill 与 subagent 使用命名提供方注册表;本地 skill 扫描项目/用户根目录,其他提供方可以在不改动注册表/工具的情况下添加嵌入式或远程目录。subagent 可以全新 spawn、从父级已完成轮次的前缀 fork,或使用 ACP 子进程([subagent.md](core-data-structures/subagent.md))。 + +### Bundle 与应用 + +`dsh-agent-spine-demo` 是默认的组合 bundle:一个插件加载共享主干([README](../packages/examples/agent-spine-demo/README.md))。应用包将其与前端入口和启动 `bin` 组合:`dsh-stdio-demo` 用于终端 REPL,`dsh-acp-demo` 用于基于 JSON-RPC stdio 的 ACP(无 stdout logger)([ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 则启动外部 `cordis.yml`;Python SDK 在未设置显式配置通道时注入包默认值,并通过行分隔的 stdio JSON-RPC 驱动 `dsh-jsonrpc`([Python SDK](../python/README.md))。一个部署就是一片薄薄的 `cordis.yml` 叶子:可替换的后端、一个应用入口和可选的产品工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[关系图索引](graph-atlas.md))。 + +### 新行为的归属 + +新行为应接入已记录的扩展点;修改内置循环需要同步更新本映射。 + +| 目标 | 机制 | +|---|---| +| 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | +| 添加面向模型的能力 | 在 `ctx.tools` 上注册工具;schema 流入 prompt 组装 | +| 添加命令执行 | 实现并注册 `ctx.bash` 后端 | +| 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方或监听 `fs/*` 策略事件 | +| 隔离 spawn 的进程 | 一个 `ctx.sandbox` 后端;消费方在 spawn 前包装 argv | +| 拦截 prompt、请求、工具使用或续行 | 监听相关的 `agent/*` 或 `tools/*` waterfall;使用串行 `agent/turn-stop` 实现单调终止 | +| 添加历史之外的会话稳定请求前缀 | 在 `agent/session-prefix` 上组合,每个循环实例一次;记录在请求 header 上 | +| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | +| 添加持久化会话状态 | 添加 `SessionEventMap` 成员并从日志渲染/回放 | +| fork 活跃会话 | 使用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| 将工具、prompt 段或监听器限定到单个 agent | 通过该 agent 的 `agent.ctx` 注册(见 Agent 作用域) | + +[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)提供插件骨架与功能到 seam 的映射;分步指南覆盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)与[vendor 包](cookbook/adding-a-vendored-package.md)。 + +## 快速参考 +- 领域术语见[术语表](glossary.md) +- 类型定义见 [core-data-structures/](core-data-structures/core.md) +- 精确的事件与服务签名见[事件目录](cordis-catalog/events.md) +- [服务目录](cordis-catalog/services.md) +- 包契约见[包映射](../packages/README.md) +- [RFC](rfc/README.md) diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml new file mode 100644 index 0000000000..405a377ed1 --- /dev/null +++ b/docs/cordis-primer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +cordis-primer.md: 39d3d97b9ac43fec50cb0c832af449fc8bc6232f +cordis-primer.zh.md: 4915f665cae51b44f89190d43d147e5cda0df146 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index d8e92dcb67..39d3d97b9a 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -1,5 +1,7 @@ # Cordis Primer +English | [中文](cordis-primer.zh.md) + Cordis is the vendored plugin framework underneath the DeepSeek Harness SDK. This primer teaches the Cordis ideas a harness plugin author needs before reading the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs. The vendored source and sync procedure live in [vendor/README.md](../vendor/README.md). ## Cordis In Five Ideas diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md new file mode 100644 index 0000000000..4915f665ca --- /dev/null +++ b/docs/cordis-primer.zh.md @@ -0,0 +1,44 @@ +# Cordis 入门 + +[English](cordis-primer.md) | 中文 + +Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。本入门文档讲解 harness 插件作者在阅读生成的[事件](cordis-catalog/events.md)与[服务](cordis-catalog/services.md)目录之前需要了解的 Cordis 核心概念。vendor 源码与同步流程见 [vendor/README.md](../vendor/README.md)。 + +## Cordis 五大理念 + +- **插件是实现了 Service 的对象。** 它可以是一个带有可选 `inject` 和 `apply(ctx)` 字段的函数,也可以是一个 `Service` 子类,其生命周期由 Cordis 挂载到当前上下文中。 +- **上下文是服务的注册表。** 一个服务在上下文中声明一个稳定的 `ctx.`(如 `ctx.tools`、`ctx.llm`、`ctx.sessions`);其他插件通过 key 查找服务,而非导入具体实现。 +- **通过 `inject` 声明服务依赖。** 插件声明所需的服务后,会等待这些服务就绪;加载顺序通过服务依赖表达,而非手动编排启动序列。 +- **类型化事件用于通信。** 服务通过 TypeScript 声明合并定义事件名,然后以 `emit`、`waterfall`(瀑布式事件)、`parallel` 或 `serial` 方式分发,分别对应监听者观察、包装、并行扇出或按序执行。 +- **注册是可逆的副作用。** 提示词片段、工具 schema、适配器、提供方和监听器通过 `ctx.effect()` 或 `ctx.on()` 安装,因此重载和拆卸能可预测地回退它们。 + +## 分发模式 + +每个事件具有以下分发模式之一,且只能通过对应的方法分发。 + +| 模式 | 是否 await? | 分发顺序 | 是否有返回值? | +|---|---|---|---| +| `emit` | 否 | 监听器按注册顺序观察 | 否 | +| `waterfall` | 否 | 监听器按注册顺序观察 | 是 | +| `parallel` | 是 | 所有监听器并行观察事件 | 否 | +| `serial` | 是 | 监听器按注册顺序观察 | 是 | + +分发模式是事件公开契约的一部分。新的 harness 事件通过 `@mode` 标签记录它,以便生成的目录能将声明与分发站点进行交叉校验。 + +## Cordis Waterfall 语义 + +`ctx.waterfall` 是环绕中间件。监听器接收 `(...args, next)`。调用 `next()` 将可能经过包装的结果委托给下一个服务;不调用 `next()` 直接返回则短路。值通过 `next()` 的返回值向下传播。 + +协作式监听器通常修改一个共享的请求或决策对象,然后委托。监听器也可以选择完全替换结果,下游监听器只会看到替换后的结果。仅当监听器必须在普通注册之前运行时才使用 `prepend: true`。 + +对于单决策事件,短路是设计意图。策略监听器在拥有决策权时可以不调用 `next()` 直接返回,而仅做标注或观察的监听器必须委托。 + +## Loader 配置 + +`@cordisjs/plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 进行插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个真值对象,总是会禁用该条目。当需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖。 + +## 实践规则 + +将行为封装到插件中:工具流水线事件属于 `ctx.tools`,模型流式输出属于 `ctx.llm`,实时 agent 协调属于 `ctx.agents`。拦截和策略优先使用事件;直接能力调用优先使用服务方法。 + +每个注册都应有对应的 dispose(资源释放)器:要么从 `ctx.effect()` 返回一个,要么使用 Cordis 提供的辅助函数自动处理。如果拆卸顺序有要求,请将相关工作放在同一个 effect 中,以确保 dispose 按预期顺序回退。 diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml new file mode 100644 index 0000000000..03765e44ae --- /dev/null +++ b/docs/defensive-patterns.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +defensive-patterns.md: fda0be0d2d3b7fa099162123b3d219673eebd07d +defensive-patterns.zh.md: 60d7389db50c30e2b85fd88b320f04b43e22d84d diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index cf30072094..fda0be0d2d 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -1,5 +1,7 @@ # Defensive patterns +English | [中文](defensive-patterns.zh.md) + Hard-won bug-class rules: each pattern below is a class of defect that actually shipped or nearly shipped here, stated as the rule that prevents its recurrence. Read this before writing lifecycle, concurrency, subprocess, or teardown code. Test-tier counterparts (real entry path, world-verification, resource ownership) are in [testing.md](testing.md). ## Report orthogonal outcomes independently diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md new file mode 100644 index 0000000000..60d7389db5 --- /dev/null +++ b/docs/defensive-patterns.zh.md @@ -0,0 +1,29 @@ +# 防御性模式 + +[English](defensive-patterns.md) | 中文 + +来之不易的缺陷类别规则:以下每条模式都是本项目中实际发布或险些发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前,请先阅读本文。测试层面的对应规则(真实入口路径、world 验证、资源归属)见 [testing.md](testing.md)。 + +## 正交结果独立上报 + +一个结果可以同时具有多重性质:进程可能既超时又以 exit 0 退出,因为它捕获了信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应独立暴露;永远不要把一个 flag 的上报嵌套在另一个 flag 的分支内,否则调用方会把一次被截断的运行误读为正常成功。 + +## 在接口两侧都遵守跨 seam 契约 + +当接口文档记录了两种有效的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须两种都处理,而不只是第一个实现碰巧使用的那种。基于库的适配器在流中途无法抛出异常,只能依赖带内路径;如果 agent loop 只捕获 throw,就会把提供方的 401 变成一个正常完成的轮次。请在类型定义处记录契约;通过真实消费方测试每个分支。 + +## 异步状态不是同步状态 + +`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞态;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。永远不要基于一个你刚刚请求的状态来控制流程——应当基于实际触发的事件/promise 来驱动生命周期(`agent/status`、`task.done`),并观察状态转换(先看到 `running` 再看到 `idle`),而不是假设你发出的动作与轮次 1:1 对应(循环会批量处理排队的消息)。这条守则是双向的:如果等待的转换永远不会发生(EOF 且没有提交过工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 + +## dispose 必须达到静止,而非仅仅请求停止 + +一个只发出 kill/abort 就返回的清理逻辑会留下孤儿进程。请让清理逻辑异步化并 await 子进程退出(kill → await `done`),并在 kill 之前关闭监听器/通知注册表,使迟到的完成事件保持静默。测试应证明 dispose 确实等到了进程退出(`await fiber.dispose()` 之后 pid 已不存在),而非仅仅证明进程最终会死。 + +## 在边界处包容回调异常 + +用户提供的监听器抛出异常时,不得导致它所在的 promise 被 reject,也不得饿死排在它之后的监听器。请在分发循环中用 try/catch 包裹并记录日志;一个有问题的订阅者永远不能破坏核心生命周期。 + +## 永远不要把环境变量或可预测路径暴露给不可信输出 + +spawn 的命令应获得一个经过清洗的 env(移除 `*KEY*`/`*SECRET*`/`*TOKEN*`),确保 harness 凭证不会泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可打开模式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞态和信息泄露。 diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml new file mode 100644 index 0000000000..a0765ef752 --- /dev/null +++ b/docs/glossary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +glossary.md: 23eebc5793e8232482a796f5bde1e794f0556208 +glossary.zh.md: 7163015eeed71c96743b9cae491db206585a70b2 diff --git a/docs/glossary.md b/docs/glossary.md index 81b9b4f84a..23eebc5793 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,5 +1,7 @@ # Glossary +English | [中文](glossary.zh.md) + Domain vocabulary for the DeepSeek Harness SDK uses one canonical term per concept. Terms link to their entries with standard Markdown anchors; implementation detail stays in package READMEs and RFCs. FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md new file mode 100644 index 0000000000..7163015eee --- /dev/null +++ b/docs/glossary.zh.md @@ -0,0 +1,19 @@ +# 术语表 + +[English](glossary.md) | 中文 + +DeepSeek Harness SDK 的领域词汇对每个概念使用唯一的规范术语。各术语通过标准 Markdown 锚点互相链接;实现细节留在各 package README 和 RFC 中。 + +FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. + +## agent 作用域 + +- **scope(作用域)**:按 agent(智能体)注册的单位。一项贡献(工具、prompt 段落、变量、限制、监听器)要么是*全局*的(对所有 agent 可见),要么是*有作用域*的(归属于恰好一个 [scope key](#scope-key))。只有两层,扁平结构:有作用域的注册不会向下继承给 subagent;子树行为通过[血统](#lineage)数据表达,从不通过作用域结构。 +- **scope key(作用域键)**:作用域的不透明标识,按对象同一性比较。harness 约定:一个活跃的 agent 就是其自身作用域的 key。 +- **agent context(`agent.ctx`)**:agent 的有作用域上下文;通过它进行的注册既是作用域可见的,也是作用域生命周期的(一个事实同时驱动两者),其上的监听器参与该 agent 的作用域过滤分发。注册表主体事件可以在其自身的事件契约下有意保持不过滤。 +- **scope carrier(作用域载体)**:作用域过滤分发所携带的 `thisArg`(由 `scopeTarget` 构建);其过滤器放行无标签监听器加上主体自身的监听器。*无主体*的载体(没有 key)只放行无标签监听器。 +- **scoped dispatch(作用域分发)**:规则是:关于某个 agent 活动的事件以该 agent 的载体进行分发。关于注册表本身的事件(如「一个工具被添加」)属于*注册表主体*事件,保持不过滤。 +- **shadowing(遮蔽)**:最具体者胜出的名称解析:一个有作用域的工具/段落/变量仅在该作用域内替代其同名的全局副本。这是按 agent 定制人设和按 agent 定制工具变体的机制。 +- **restriction / scope-local registration(限制 / 作用域局部注册)**:限制(`tools.restrict`)为单个作用域过滤全局工具面(按交集组合);作用域局部注册在过滤之后合并。被过滤掉的全局工具既不出现在 prompt 中,也拒绝执行,与不存在的工具无法区分。 +- **setup window(设置窗口)**:创建者组装 agent 有作用域世界的创建时隙(`CreateAgentOptions.setup`):在作用域和 agent 对象已存在、但 agent 或会话尚未发布、`agent/session-start` 尚未触发、首次 prompt 尚未组装之前。设置窗口只做注册,从不驱动 agent。 +- **lineage(血统)**:以数据形式携带的父子关系(`parentSession`、`subagentDepth`);从不影响可见性。 diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml new file mode 100644 index 0000000000..3adcf28723 --- /dev/null +++ b/docs/testing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +testing.md: ddb9da0b38e5dc9cc75ede81ec157c4744fd11c2 +testing.zh.md: 6d21a37b175c8052fbc34db0fc5a9b522c27f7ec diff --git a/docs/testing.md b/docs/testing.md index d4acdc33c4..ddb9da0b38 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,5 +1,7 @@ # Testing policy +English | [中文](testing.zh.md) + How this repo tests, tier by tier, and the rules that keep a green suite meaningful. Commands live in root [AGENTS.md](../AGENTS.md); linked RFCs carry the rationale. ## Tiers diff --git a/docs/testing.zh.md b/docs/testing.zh.md new file mode 100644 index 0000000000..6d21a37b17 --- /dev/null +++ b/docs/testing.zh.md @@ -0,0 +1,35 @@ +# 测试策略 + +[English](testing.md) | 中文 + +本文说明本仓库如何逐层测试,以及保持绿色测试套件有意义的规则。命令见根目录 [AGENTS.md](../AGENTS.md);关联 RFC 承载设计动机。 + +## 层级 + +- **单元测试**(`pnpm run test`):vitest 运行 `packages|examples/*/tests/**/*.spec.ts`,与被测代码同目录。每个注册表都有一个 HMR(热模块替换)安全测试(dispose 贡献该注册的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态与永久契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 +- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,绝非充分条件:它证明代码行被执行过,不证明功能按交付预期工作。 +- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试,对接真实提供方 API——DeepSeek 模型加各提供方独立冒烟测试(各自依赖自己的密钥:`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等);每个套件在缺少对应密钥时自动跳过,确保无密钥 CI 保持绿色([真实 API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 +- **快照测试**(`pnpm run test:snapshot`):启动真实示例子进程,无密钥回放录制的会话,将归一化后的 stdout 与重新持久化的日志同已提交的 golden 文件做 diff([快照 RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md))。当模型 transcript(文本记录)需要变更时使用 `pnpm run test:snapshot:record`;当已提交的 transcript 仍是正确的 mock LLM(大语言模型)输入、只需无密钥重写回放 golden 时使用 `pnpm run test:snapshot:refresh`。请评审 golden diff。系统提示词/工具 schema 内容由一个场景(`text-turn`)固定,其余 fixture(测试前置数据)中以 token 化形式引用,因此 prompt 或 schema 的修改只变动一行已提交内容([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 + +## 带密钥策略:推理在这里很便宜 + +我们是 DeepSeek:不要吝惜真实 API 测试。无密钥测试证明管道通了;只有带密钥运行才能证明 agent 对接真实模型时能正常工作。多写:文件写入 prompt、多轮对话、工具调用、流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条真实 prompt、检查外部世界的状态。它们能捕获「单元测试全绿、产品却坏了」这类 mock 在结构上无法发现的问题([事后分析 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过机制的存在仅仅是为了不阻塞无密钥 CI 和无密钥贡献者,它不是成本信号。每个示例都附带一个无密钥冒烟测试,以及(除非本身就不需要密钥)一个带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 + +## 优先使用真实实现而非 mock + +只 mock 真正昂贵或不确定的边界(LLM 适配器、网络、时钟);下游一切保持真实。手写的替身只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言——两者会漂移,而测试仍然绿着。示例:bridge 工具调用测试运行脚本化的 mock 模型,但使用真实的 tool + 真实的执行器(`makeBridgeHarness({ withBash: true })` 接入 `dsh-bash-local` + `dsh-tool-bash`,执行真正的 `echo`)。 + +## 验证外部世界,而非自我报告 + +e2e 断言应重新运行命令或从外部重新读取文件;仅对 agent 自身输出做关键词探测会让作弊的 agent 通过。断言未改动的文件字节相同。e2e 测试拥有自己的资源:在测试中创建 harness,在 `afterEach` 中 dispose(即使失败/重试/超时);共享 fixture 放在普通的 `tests/harness.ts` 中,绝不放在另一个 `*.e2e.ts` 里(import 一个 spec 会重新注册其 `describe`,导致真实 API 调用重复)。 + +## 测试真实入口路径 + +- 产品可见的插件需要一个非单元的真实组合测试。手工搭建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部/不确定边界,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 混入默认交付。 +- 一个守卫只有在回归真正让它失败时才算守卫。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿——需要加一个显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、还原。 +- 「真实入口路径」指已发布的产物:package 的 `bin` 指向构建出的 `lib/bin.js`,在原生 `node` 下运行;tsx 会掩盖竞态、模块解析问题以及静默以 0 退出的加载失败。同理适用于构建后 package 在运行时解析的任何非 index 运行时入口(worker-thread 运行时的兄弟文件 `lib/worker.cjs`)。保持构建产物冒烟测试绿色(`packages/ui/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零退出。 +- 从临时 cwd spawn 示例的 e2e 测试需要设置 `TSX_TSCONFIG_PATH` 指向仓库根 tsconfig,否则会静默回退到陈旧的构建 `lib/`([examples/AGENTS.md](../examples/AGENTS.md))。 + +## 何时需要快照测试 + +任何影响编辑器侧 transcript 或端到端 agent 用户体验的变更——ACP bridge、agent loop(智能体循环)的可观测输出、工具呈现——都应在所属示例的快照套件中添加或更新场景(`examples//tests/snapshots/`,基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表;`examples/acp-agent` 是主套件),或在 PR 中说明为何不适用。新的能力 seam、生命周期形态或 transcript 表面在计划阶段就要标明各层的覆盖方式,并验证 harness 能表达它——harness 的缺口是排期工作,不是构建中途的意外。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 35a957e57d..ee0a35b599 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -2,31 +2,41 @@ "requiredSince": "2026-07-14", "required": [ "README.md", + "docs/architecture.md", "docs/cookbook/adding-a-package.md", "docs/cookbook/adding-a-tool.md", "docs/cookbook/adding-a-vendored-package.md", "docs/cookbook/adding-an-llm-adapter.md", "docs/cookbook/extension-cookbook.md", "docs/cookbook/responding-to-pr-review-on-a-stack.md", + "docs/cordis-primer.md", + "docs/defensive-patterns.md", "docs/development.md", + "docs/glossary.md", "docs/i18n/README.md", "docs/i18n/translation-rules.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", + "docs/testing.md", "python/README.md", "python/sdk-runtime/README.md", "python/sdk/README.md" ], "excluded": [ "docs/AGENTS.md", + "docs/agent-lifecycle.md", + "docs/capability-seams.md", "docs/config-catalog.md", "docs/cordis-catalog/", + "docs/event-producer-consumer.md", + "docs/graph-atlas.md", "docs/i18n/style-samples.md", "docs/i18n/terminology.md", "docs/i18n/translation-prompt.md", "docs/module-graph.md", "docs/persistence-catalog.md", "docs/tool-catalog.md", + "docs/tool-execution-pipeline.md", "python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/" ] } From 5270dcd61df1e4f5773e6c6ac6e700c38d77b33d Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:11:25 -0700 Subject: [PATCH 002/113] =?UTF-8?q?docs(i18n):=20core-data-structures=20an?= =?UTF-8?q?d=20postmortem=20batch=20=E2=80=94=2022=20bilingual=20pairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core-data-structures 18 篇(core.md 因超长仍在产出、随后补)、 postmortem 3 篇与 RFC 前门 README 配对;流水线 + 二遍校验产出。 生成文件 docs/rfc/INDEX.md(gen-rfc-index 产物)列入排除。中文侧 页内锚点统一指向英文侧锚名,满足配对门禁的链接目标一致规则。 --- docs/core-data-structures/approval.i18n.yaml | 6 + docs/core-data-structures/approval.md | 2 + docs/core-data-structures/approval.zh.md | 66 ++++ docs/core-data-structures/bash.i18n.yaml | 6 + docs/core-data-structures/bash.md | 2 + docs/core-data-structures/bash.zh.md | 245 ++++++++++++++ .../code-runtime.i18n.yaml | 6 + docs/core-data-structures/code-runtime.md | 2 + docs/core-data-structures/code-runtime.zh.md | 85 +++++ .../core-data-structures/compaction.i18n.yaml | 6 + docs/core-data-structures/compaction.md | 2 + docs/core-data-structures/compaction.zh.md | 57 ++++ .../core-data-structures/filesystem.i18n.yaml | 6 + docs/core-data-structures/filesystem.md | 2 + docs/core-data-structures/filesystem.zh.md | 149 +++++++++ .../llm-streaming.i18n.yaml | 6 + docs/core-data-structures/llm-streaming.md | 2 + docs/core-data-structures/llm-streaming.zh.md | 80 +++++ .../persistence.i18n.yaml | 6 + docs/core-data-structures/persistence.md | 2 + docs/core-data-structures/persistence.zh.md | 90 +++++ docs/core-data-structures/sandbox.i18n.yaml | 6 + docs/core-data-structures/sandbox.md | 2 + docs/core-data-structures/sandbox.zh.md | 84 +++++ docs/core-data-structures/scope.i18n.yaml | 6 + docs/core-data-structures/scope.md | 2 + docs/core-data-structures/scope.zh.md | 33 ++ .../session-query.i18n.yaml | 6 + docs/core-data-structures/session-query.md | 2 + docs/core-data-structures/session-query.zh.md | 71 ++++ docs/core-data-structures/session.i18n.yaml | 6 + docs/core-data-structures/session.md | 2 + docs/core-data-structures/session.zh.md | 312 ++++++++++++++++++ docs/core-data-structures/skills.i18n.yaml | 6 + docs/core-data-structures/skills.md | 2 + docs/core-data-structures/skills.zh.md | 120 +++++++ docs/core-data-structures/subagent.i18n.yaml | 6 + docs/core-data-structures/subagent.md | 2 + docs/core-data-structures/subagent.zh.md | 101 ++++++ .../system-prompt.i18n.yaml | 6 + docs/core-data-structures/system-prompt.md | 2 + docs/core-data-structures/system-prompt.zh.md | 40 +++ docs/core-data-structures/tools.i18n.yaml | 6 + docs/core-data-structures/tools.md | 2 + docs/core-data-structures/tools.zh.md | 237 +++++++++++++ .../user-interaction.i18n.yaml | 6 + docs/core-data-structures/user-interaction.md | 2 + .../user-interaction.zh.md | 99 ++++++ docs/core-data-structures/web.i18n.yaml | 6 + docs/core-data-structures/web.md | 2 + docs/core-data-structures/web.zh.md | 86 +++++ docs/core-data-structures/workflow.i18n.yaml | 6 + docs/core-data-structures/workflow.md | 2 + docs/core-data-structures/workflow.zh.md | 71 ++++ ...-acp-default-export-drops-inject.i18n.yaml | 6 + .../0001-acp-default-export-drops-inject.md | 2 + ...0001-acp-default-export-drops-inject.zh.md | 113 +++++++ ...ession-disabled-filesystem-tools.i18n.yaml | 6 + ...js-expression-disabled-filesystem-tools.md | 2 + ...expression-disabled-filesystem-tools.zh.md | 47 +++ docs/postmortem/README.i18n.yaml | 6 + docs/postmortem/README.md | 2 + docs/postmortem/README.zh.md | 16 + docs/rfc/README.i18n.yaml | 6 + docs/rfc/README.md | 2 + docs/rfc/README.zh.md | 111 +++++++ scripts/translation-pairing.manifest.json | 23 ++ 67 files changed, 2512 insertions(+) create mode 100644 docs/core-data-structures/approval.i18n.yaml create mode 100644 docs/core-data-structures/approval.zh.md create mode 100644 docs/core-data-structures/bash.i18n.yaml create mode 100644 docs/core-data-structures/bash.zh.md create mode 100644 docs/core-data-structures/code-runtime.i18n.yaml create mode 100644 docs/core-data-structures/code-runtime.zh.md create mode 100644 docs/core-data-structures/compaction.i18n.yaml create mode 100644 docs/core-data-structures/compaction.zh.md create mode 100644 docs/core-data-structures/filesystem.i18n.yaml create mode 100644 docs/core-data-structures/filesystem.zh.md create mode 100644 docs/core-data-structures/llm-streaming.i18n.yaml create mode 100644 docs/core-data-structures/llm-streaming.zh.md create mode 100644 docs/core-data-structures/persistence.i18n.yaml create mode 100644 docs/core-data-structures/persistence.zh.md create mode 100644 docs/core-data-structures/sandbox.i18n.yaml create mode 100644 docs/core-data-structures/sandbox.zh.md create mode 100644 docs/core-data-structures/scope.i18n.yaml create mode 100644 docs/core-data-structures/scope.zh.md create mode 100644 docs/core-data-structures/session-query.i18n.yaml create mode 100644 docs/core-data-structures/session-query.zh.md create mode 100644 docs/core-data-structures/session.i18n.yaml create mode 100644 docs/core-data-structures/session.zh.md create mode 100644 docs/core-data-structures/skills.i18n.yaml create mode 100644 docs/core-data-structures/skills.zh.md create mode 100644 docs/core-data-structures/subagent.i18n.yaml create mode 100644 docs/core-data-structures/subagent.zh.md create mode 100644 docs/core-data-structures/system-prompt.i18n.yaml create mode 100644 docs/core-data-structures/system-prompt.zh.md create mode 100644 docs/core-data-structures/tools.i18n.yaml create mode 100644 docs/core-data-structures/tools.zh.md create mode 100644 docs/core-data-structures/user-interaction.i18n.yaml create mode 100644 docs/core-data-structures/user-interaction.zh.md create mode 100644 docs/core-data-structures/web.i18n.yaml create mode 100644 docs/core-data-structures/web.zh.md create mode 100644 docs/core-data-structures/workflow.i18n.yaml create mode 100644 docs/core-data-structures/workflow.zh.md create mode 100644 docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml create mode 100644 docs/postmortem/0001-acp-default-export-drops-inject.zh.md create mode 100644 docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml create mode 100644 docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md create mode 100644 docs/postmortem/README.i18n.yaml create mode 100644 docs/postmortem/README.zh.md create mode 100644 docs/rfc/README.i18n.yaml create mode 100644 docs/rfc/README.zh.md diff --git a/docs/core-data-structures/approval.i18n.yaml b/docs/core-data-structures/approval.i18n.yaml new file mode 100644 index 0000000000..f51b3ab961 --- /dev/null +++ b/docs/core-data-structures/approval.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +approval.md: 772582955145092f3d483c297f7704b2b8506375 +approval.zh.md: dc45e1c6969099a2b285b4da071153510356acce diff --git a/docs/core-data-structures/approval.md b/docs/core-data-structures/approval.md index c5fa1fe13b..7725829551 100644 --- a/docs/core-data-structures/approval.md +++ b/docs/core-data-structures/approval.md @@ -1,5 +1,7 @@ # User Approval +English | [中文](approval.zh.md) + The user-approval seam of [dsh-user-approval](../../packages/ui/user-approval) answers one question: may this specific action proceed? It owns the shared request/outcome vocabulary, the `ctx.approval` dispatch service, the `approval/request` answerer waterfall, the log-only audit pair, and the per-session `ask`/`never` policy. UI channels such as [dsh-acp](../../packages/ui/acp) provide answerers; callers such as [dsh-tools](../../packages/core/tools) and [dsh-tool-bash](../../packages/bash/tool-bash) consume the closed outcome and fail closed unless it is `allowed-once`. Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approval/src/index.ts) diff --git a/docs/core-data-structures/approval.zh.md b/docs/core-data-structures/approval.zh.md new file mode 100644 index 0000000000..dc45e1c696 --- /dev/null +++ b/docs/core-data-structures/approval.zh.md @@ -0,0 +1,66 @@ +# 用户审批 + +[English](approval.md) | 中文 + +[dsh-user-approval](../../packages/ui/user-approval) 的用户审批 seam 回答一个问题:这个具体操作是否可以继续?它拥有共享的请求/结果词汇、`ctx.approval` 分发服务、`approval/request` 应答者 waterfall(瀑布式事件)、仅记录日志的审计事件对,以及按会话的 `ask`/`never` 策略。UI 通道(如 [dsh-acp](../../packages/ui/acp))提供应答者;调用方(如 [dsh-tools](../../packages/core/tools) 和 [dsh-tool-bash](../../packages/bash/tool-bash))消费封闭的结果,并在结果不是 `allowed-once` 时默认拒绝。 + +源码:[`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approval/src/index.ts) + +## 标识与结果 + +每个请求获得一个新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时防止审批 id 与工具调用、会话或 agent id 混用。 + +```ts type-equiv +type ApprovalRequestId = Branded<'ApprovalRequestId'> +``` + +`ApprovalOutcome` 是封闭的,且默认拒绝。`allowed-once` 仅授权被询问的那个操作;调用方在遇到 `rejected`、`cancelled` 和 `unavailable` 时一律拒绝。缺失的、不拥有该请求的、抛出异常的或不符合规范的应答者会产生 `unavailable`,而不是放行。 + +```ts type-equiv +type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' +``` + +## 按会话策略 + +`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,其无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值取会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 + +```ts type-equiv +type ApprovalPolicy = 'ask' | 'never' +``` + +提示词段落会声明 `never` 的确定性行为,并用服务自有的标记记录当前策略。重启后,pre-step 叙述器从已记录的请求头中读取该标记;它不从部署 persona 行文中推断状态。ACP 中空闲时的策略切换会被桥接层持有到下一次 `turn/start`,因为审批审计事件和策略事件必须保持在轮次内,以确保持久回放的正确性。 + +## 审批请求 + +`ApprovalRequest` 足够精确地标识 agent 和工具操作,以便路由和审计该问题。它有意省略工具参数:应答者通过 `callId` 将提示附加到已流式输出的工具调用上,而不是渲染可能漂移的第二份副本。 + +```ts type-equiv +interface ApprovalRequest { + /** + * The agent on whose behalf the question is asked. Routes the question (a + * UI answerer only answers for agents it owns) and receives the audit + * events on its session log. + */ + readonly agent: Agent + /** The tool the question is about (presentation and audit). */ + readonly toolName: string + /** + * The exact tool call being decided, when the asker has one — lets a UI + * attach the prompt to the tool call it already streamed. + */ + readonly callId?: CallId + /** The asker's human-readable explanation of WHY it is asking. */ + readonly reason?: string + /** + * Aborting withdraws the question: the request settles `'cancelled'` + * immediately and a late answer from a still-pending answerer is discarded. + */ + readonly signal?: AbortSignal +} +``` + +## 分发与审计 + +`ctx.approval.request(req)` 要求发起请求的会话处于一个打开的轮次内。它追加 `approval/asked`,获取一个结果,追加匹配的 `approval/decided`,然后以该结果 resolve。`never` 策略在服务内部、waterfall 分发之前就已强制执行,因此即使后来用 `prepend` 注册的应答者也无法绕过它。应答者在拥有该请求时返回结果,否则调用 `next()` 委托;第一个应答占据唯一的决策槽位。 + +审计事件仅记录日志,不进入模型 transcript(文本记录)。模型可见的行为是调用方派生的工具结果,而请求头记录的是模型实际看到的提示词策略。服务 dispose(资源释放)时会同时移除其提示词段落和 pre-step 叙述器;应答者监听器独立地通过 effect 绑定到其所属插件。 diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml new file mode 100644 index 0000000000..323300cc4e --- /dev/null +++ b/docs/core-data-structures/bash.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +bash.md: 7b5c779b832ef5be6591e626980f7f22db54f239 +bash.zh.md: 519a7bf973fdf9fd9d7c4be2cc6ebf77e576135d diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index a5efe1a504..7b5c779b83 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -1,5 +1,7 @@ # Bash Executor +English | [中文](bash.zh.md) + The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface. Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md new file mode 100644 index 0000000000..519a7bf973 --- /dev/null +++ b/docs/core-data-structures/bash.zh.md @@ -0,0 +1,245 @@ +# Bash 执行器 + +[English](bash.md) | 中文 + +Bash 执行 seam:典型的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) 示例,拆分为三个包(package):接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local),本地子进程)、消费方([dsh-tool-bash](../../packages/bash/tool-bash),`bash`/`bash_output`/`bash_kill` 工具 schema)。Bash 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。沙箱化、容器化或远程后端只需作为兄弟包实现同一接口。 + +源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) + +## 请求与规格:`resolve()` 拆分 + +该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs` 可选,由配置填充)与**执行器实际执行的完全解析规格**(这些字段为必填)分开。工具层在二者之间调用 `ctx.bash.resolve(request)`。这是本仓库「包边界处显式优于隐式」规则的具体体现:读到一个 `BashExecSpec` 的人永远不必猜测工作目录从何而来。 + +```ts type-equiv +interface BashExecRequest { + command: string + /** Working directory override (default: implementation-configured). */ + workdir?: string | undefined + /** Timeout override in milliseconds (implementations cap it). */ + timeoutMs?: number | undefined + /** Abort signal — implementations kill the command when it fires. */ + signal?: AbortSignal | undefined + /** + * Bytes to write to the command's stdin, then close it. Absent leaves stdin + * closed/empty (the default for model-driven tool calls). Set by in-process + * plugins (e.g. the hooks bridges, which write a hook command's JSON payload + * to its stdin); the model-facing bash tool does not expose it as a parameter + * (a model that needs stdin uses shell syntax like a heredoc or a pipe). + */ + stdin?: string | undefined + /** + * Extra environment entries for the command, merged AFTER the + * implementation's credential scrub (so an explicit entry here is honored even + * when its name matches the scrub pattern — the caller named a value it holds, + * not the harness's ambient secret). Set by in-process plugins (the hooks + * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing + * bash tool does not expose it as a parameter (a model that needs an env var + * uses shell syntax like `FOO=bar cmd`). + */ + env?: Record | undefined + /** + * Opaque OWNER token for a background task — the consumer's isolation key + * (the tool layer passes the owning agent's `session.header.id`). The + * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf}; + * the executor itself NEVER interprets it (no access policy lives in the + * seam — that is the consumer's job). Absent for foreground runs and for an + * ownerless background start (a non-agent caller). + */ + owner?: OwnerToken | undefined + /** + * Explicit per-call sandbox-policy input, overriding the executor's + * configured default mode for THIS call. Never a silent default: a + * consumer sets it only from an explicit policy source — an + * `'allowed-once'` grant a human just issued through `ctx.approval` (the + * escalation flow in the sandbox RFC § Escalation, which outranks), or the + * session's standing override folded from its own `bash/sandbox-mode` + * events (the sandbox RFC § Per-session mode switching — the user's recorded per-session + * choice). A sandboxing executor confines THIS call under the given mode; + * a non-sandboxing executor carries the field and confines nothing (the + * tool layer stamps neither escalation nor overrides without a sandboxing + * executor — see {@link BashExecutor.sandboxMode}). + */ + sandboxMode?: SandboxMode | undefined +} +``` + +```ts type-equiv +interface BashExecSpec { + command: string + workdir: string + timeoutMs: number + /** Abort signal — implementations kill the command when it fires. */ + signal?: AbortSignal | undefined + /** + * Bytes to write to the command's stdin (then close it), carried through + * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec + * (unlike `owner`): it has no config default, so a missing one means "no + * stdin" — the safe, ordinary case — not a silent footgun, so it stays a + * plain optional rather than required-but-nullable (see the request field). + */ + stdin?: string | undefined + /** + * Extra environment entries, carried through verbatim from + * {@link BashExecRequest.env} and merged by the implementation AFTER its + * credential scrub (an explicit entry wins even when its name matches the + * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no + * config default, absent means "no extra env". + */ + env?: Record | undefined + /** + * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs` + * being required on the resolved spec): {@link BashExecutor.resolve} carries + * the request's `owner` through, defaulting a missing one to `undefined`. A + * required field makes a forgotten owner a VISIBLE `undefined` rather than a + * silently-absent property that yields an unowned (cross-session-readable) + * task. `start()` stores it; `run()` (foreground) ignores it. + */ + owner: OwnerToken | undefined + /** + * The sandbox mode this call executes under, REQUIRED-but-nullable for the + * same visibility reason as `owner`. A sandboxing executor's `resolve()` + * stamps the effective mode (the request's explicit override, else its + * configured default) so `run()`/`start()` read the spec, never the config; + * a non-sandboxing executor carries the request value through verbatim and + * ignores it (`undefined` under such an executor means what its README says: + * unconfined execution). + */ + sandboxMode: SandboxMode | undefined +} +``` + +`owner` token 是隔离键:执行器存储它但从不解释它(访问策略是消费方的职责),因此一个 agent 启动的后台任务不会被跨会话读取。必填但可空的字段设计使得遗忘 owner 会表现为一个可见的 `undefined`,而非一个静默无主的任务。 + +受信的进程内插件使用 `stdin` 和 `env` 传递钩子载荷和钩子专用变量。面向模型的 bash 工具从其命名 schema 字段构造请求,不暴露这两个输入,因为 shell 语法已提供等价能力;测试会防止未来出现 `...args` 展开。这是请求形状纪律,而非安全边界:`dsh-bash-local` 无论这些字段如何都会清洗环境凭证,然后叠加调用方已持有的显式值。详见 [bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 + +该 seam 处理的两个 id 都是[品牌化](core.md)的(零成本 `string` 品牌,与 `SessionId`/`AgentId` 同一套机制):`BashTaskId`(被追踪的后台任务,由本地执行器生成 `bash-N`)和 `OwnerToken`(不透明的隔离键)。`OwnerToken` 刻意是与 `SessionId` **不同**的品牌,而非别名:bash seam 是一个能力 seam,它不得知道 owner token *意味着什么*,因此从不导入 `dsh-session` 的词汇。将所属 agent 的 `SessionId` 转换为 `OwnerToken` 的唯一边界是 `dsh-tool-bash` 消费方。对两者都做品牌化,可以防止裸 `string`(或在需要 `OwnerToken` 的位置传入 `BashTaskId`,反之亦然)在面向模型的 `task_id` 路径上通过类型检查。 + +## 前台运行:`BashRunResult` + +一次已完成(或被终止)的前台运行的结果。正交的结果**独立报告**:一个进程可以既超时又以 exit 0 退出(因为它捕获了信号),因此 `timedOut`、`aborted`、`signal` 和 `exitCode` 各自独立为一个字段;调用方永远不会把一次被截断的运行误读为干净的成功。 + +```ts type-equiv +interface BashRunResult { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null + /** True when the executor's own timeout killed the command. */ + timedOut: boolean + /** True when the caller's AbortSignal killed the command. */ + aborted: boolean + /** The effective timeout applied to this run (after defaulting/capping). */ + timeoutMs: number + stdout: CollectedOutput + stderr: CollectedOutput + /** + * Sandbox facts, present iff a sandboxing executor ran the command — an + * unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See + * {@link BashSandboxInfo} for the `denied` classification semantics. + */ + sandbox?: BashSandboxInfo +} +``` + +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件: + +```ts type-equiv +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## 文件沙箱:`BashSandboxInfo` + +消费沙箱的执行器(`dsh-bash-sandbox`)通过 `BashExecutor.sandboxMode` 暴露其配置的回退模式。工具层折叠每个 agent 会话的持久 `bash/sandbox-mode` 覆盖,将生效模式盖章到请求上,并可能为一次用户批准的严格更宽调用替换它。工具层刻意不声明当前模式,也不叙述切换过程;拒绝结果会指明该命令实际运行时所处的模式。模式/强制词汇由 [`@deepseek-ai/dsh-sandbox` seam](sandbox.md) 拥有并编目,其提供方包装执行器的 argv;模式仅管辖文件效果,不管网络或进程可见性。 + +沙箱化运行始终在 `BashRunResult.sandbox` 上报告其执行时的事实:`denied` 是执行器对「失败由沙箱引起」的保守分类(一次失败退出且 stderr 带有文件系统权限签名——从不是干净退出或信号终止),从收集的 stderr 尾部读取;`enforcement` 报告所选后端对该模式文件效果的治理完整度(`SandboxEnforcement = 'full' | 'partial'`——当较旧的 Landlock ABI 仅治理所请求访问的子集时为 `partial`;`danger-full-access` 下不存在,因为什么都没被限制);`runnerFailed` 标记与拒绝相反的情况——沙箱 runner 本身失败,命令从未运行(仅在已结算的后台任务上盖章;前台运行通过抛出 `SANDBOX_UNAVAILABLE` 错误暴露同一状况): + +```ts type-equiv +interface BashSandboxInfo { + /** The mode the command actually ran under. */ + mode: SandboxMode + /** + * True when the executor classifies this run's failure as the sandbox + * denying a file operation. The classification is CONSERVATIVE (a failed + * exit whose stderr carries a filesystem-permission signature) and reads + * the COLLECTED stderr — the bounded in-memory tail per + * {@link CollectedOutput} semantics, so a signature that survives only in a + * spill file is missed toward `denied: false`. A plain command failure + * keeps `denied: false` even under a sandboxed mode. + */ + denied: boolean + /** + * How completely the runner enforced `mode`'s file effects — see + * {@link SandboxEnforcement}. Absent exactly when `mode` is + * `danger-full-access`: nothing is confined, so there is no enforcement to + * report. + */ + enforcement?: SandboxEnforcement + /** + * True when the executor classifies this failure as the SANDBOX RUNNER + * itself failing (missing binary, refused profile, fail-closed refusal + * before exec) — the command NEVER RAN; this is a sandbox failure, not a + * task failure, and it outranks `denied` (a runner's own error text can + * contain denial words). Only ever stamped on settled BACKGROUND tasks: a + * foreground run surfaces the same condition as the thrown + * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error + * channel; a settled task's facts are its only channel). + */ + runnerFailed?: boolean +} +``` + +还有一个词汇完成整幅图景:`SANDBOX_UNAVAILABLE` 错误码(由 [sandbox seam](sandbox.md) 拥有)是 `ctx.sandbox` 提供方在受限模式没有可用后端时抛出的——执行器将其传播。所选 runner 拒绝其 profile 也会到达同一个快速失败的前台错误;已结算的后台任务则记录 `runnerFailed`。模型在结果中收到拒绝/runner 事实,仅在拒绝标记指明模式时才得知生效模式,并可通过 `sandbox_permissions` 加 `justification` 请求一次严格更宽的重试;`ctx.approval` 必须在任何执行之前批准该确切调用。完整的策略与切换设计见 [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md)。 + +## 后台任务:`BashTask` + +通过 `start()` 启动的长时间运行命令被追踪为 `BashTask`。`BashTaskStatus` 为 `'running' | 'completed' | 'killed'`;`done` 在底层进程关闭时 resolve,从不 reject。沙箱化执行器在任务结算后盖章 `sandbox`(分类针对已结算任务收集的 stderr 运行),因此该字段在运行中以及非沙箱化执行器下不存在。 + +```ts type-equiv +interface BashTask { + readonly id: BashTaskId + status: BashTaskStatus + /** Exit code once finished (null = killed by signal / still running). */ + exitCode: number | null + /** Terminating signal name, when signal-killed. */ + signal: NodeJS.Signals | null + /** Resolves when the underlying process closes (never rejects). */ + readonly done: Promise + /** + * Sandbox facts for this task's execution, stamped by a sandboxing executor + * once the task settles and BEFORE completion listeners are notified — an + * `onTaskDone` consumer and a `done` awaiter both see it. Denial + * classification runs against the settled task's collected stderr, so the + * field cannot exist earlier: absent while the task is running and under an + * executor that does not sandbox. See {@link BashSandboxInfo} for the + * `denied` semantics. + */ + sandbox?: BashSandboxInfo +} +``` + +`readOutput()` 返回增量的 `BashTaskRead`:自上次读取以来产生的输出,附带一个 `lossy` 标志表示截断丢弃了未读字节: + +```ts type-equiv +interface BashTaskRead { + task: BashTask + /** Output produced since the previous read (stderr in a marked section). */ + delta: string + /** True when truncation dropped unread bytes the delta cannot include. */ + lossy: boolean + /** Full stdout spill file, when stdout truncation occurred and a safe path is available. */ + stdoutSpillPath?: string + /** Full stderr spill file, when stderr truncation occurred and a safe path is available. */ + stderrSpillPath?: string +} +``` + +## 服务 + +`BashExecutor`(`ctx.bash`,抽象——定义于 [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts))镜像 `LlmService`/`LlmAdapter` 的拆分:`resolve`(请求→规格)、`run`(前台)、`start`(后台)、`get`/`ownerOf`/`list`/`readOutput`/`kill`,以及 `onTaskDone`(`BashTaskListener` 完成回调)。spawn 的命令获得一个**清洗后的 env**(丢弃 `*KEY*`/`*SECRET*`/`*TOKEN*`),溢出文件使用一个权限为 0700 的私有目录(随机文件名、仅所有者可打开)——模型输出永远拿不到宿主环境或可预测路径。提供这一切的实现是 `dsh-bash-local`;调用它的面向模型的 `bash`/`bash_output`/`bash_kill` schema 位于 `dsh-tool-bash`(并通过[工具呈现词汇](tools.md#tool-presentation-ui-vocabulary)以终端形式展示)。 diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml new file mode 100644 index 0000000000..b2c8f97ae1 --- /dev/null +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +code-runtime.md: 28152947d0853fb10228c472ca3e121e77b7b598 +code-runtime.zh.md: f12816fd5392f1efff3a1faeee232fb004142f37 diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index 9237a3cce9..28152947d0 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -1,5 +1,7 @@ # Code Runtime +English | [中文](code-runtime.zh.md) + The code-execution seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and the tool-registry consumer (Code Mode) are specified in the [Code Mode RFC](../rfc/implemented/feature/2026-06-15-code-mode.md). Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md new file mode 100644 index 0000000000..f12816fd53 --- /dev/null +++ b/docs/core-data-structures/code-runtime.zh.md @@ -0,0 +1,85 @@ +# 代码运行时 + +[English](code-runtime.md) | 中文 + +代码执行 seam:一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)负责运行一段模型编写的程序,对接宿主提供的异步绑定,并报告程序打印和返回的内容。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。后端因执行基底和源语言而异,二者均为服务上的只读描述符;worker-thread 后端与工具注册表消费方(Code Mode)在 [Code Mode RFC](../rfc/implemented/feature/2026-06-15-code-mode.md) 中规定。 + +源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) + +## 运行:请求进,结果出 + +`CodeRunRequest` 携带**运行时所需的全部信息**。按照「包(package)seam 处显式优于隐式」的规则,默认值(时间预算、输出上限)由实现的已校验配置提供,绝不是 `run()` 内部隐藏的 `??`: + +```ts type-equiv +interface CodeRunRequest { + /** + * The program source, in the runtime's {@link ../index.ts | language}. It + * runs as the body of an async function: top-level `await` and `return` + * are available, and the completion value becomes + * {@link CodeRunResult.value}. + */ + program: string + /** Host functions exposed to the program, one global object per namespace. */ + bindings: CodeBindingNamespace[] + /** + * Abort the run: the runtime stops the program (hard, even mid-loop) and + * resolves with a {@link CodeRunFailure} of kind `'abort'`. In-flight + * binding calls are the CALLER's to settle — the runtime only stops asking. + */ + signal?: AbortSignal +} +``` + +结果将错误报告为一个**字段**,而非 `run()` 的 rejection:报告程序失败是调用方的职责,不是异常路径(与 `BashExecutor.run` 的 resolve-on-failure 契约一致): + +```ts type-equiv +interface CodeRunResult { + /** + * The program's completion value (its top-level `return`), when it ran to + * completion and the value survived the runtime's serialization boundary; + * a non-transferable value is replaced by a string rendering, and a failed + * or value-less run leaves this absent. + */ + value?: unknown + /** Text the program emitted, in order (capped by the implementation). */ + logs: string[] + /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ + error?: CodeRunFailure +} +``` + +## 绑定:宿主函数作为程序全局变量 + +每个 `CodeBindingNamespace` 在程序内部成为一个由异步可调用成员组成的全局对象(Code Mode 消费方传入一个:`tools`)。参数与解析值必须可 structured-clone:运行时可能跨序列化边界桥接调用。运行时将绑定名视为不可信输入(`__proto__` 是普通的 own property,绝不会产生原型碰撞): + +```ts type-equiv +interface CodeBindingNamespace { + /** The global identifier the program sees (must be a valid JS identifier). */ + global: string + /** The callable members, keyed by the exact name the program calls. */ + functions: Record +} +``` + +```ts type-equiv +type CodeBindingFunction = (args: unknown) => Promise +``` + +## 捕获的输出与失败分类体系 + +日志是按发出顺序排列的纯字符串。运行时捕获程序的 console 和流输出,但通道与 console 方法的元数据不属于 seam 的一部分,因为消费方只渲染文本。实现对聚合输出设上限,并在输出内标记截断。 + +失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者之一: + +```ts type-equiv +interface CodeRunFailure { + /** The failure class (see the interface doc for each kind's meaning). */ + kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' + /** Human-readable detail, suitable for feeding back to a model to self-correct. */ + message: string +} +``` + +## 服务 + +`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))是 `run(request)` 加两个只读描述符:`language`(程序必须使用的语言:`'typescript'` 是已知值;生成语言相关展示的消费方据此分支,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底:`'worker-thread'`、`'process'`、`'container'`;是诊断标签,**不是安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时达到静止状态:进行中的运行在 teardown 完成前被终止并 await。 diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml new file mode 100644 index 0000000000..a5dda25c68 --- /dev/null +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +compaction.md: e82cc103932ad05e68bc5311dec23c3f2c1a7ce4 +compaction.zh.md: 889cdba416767e90592359361fc0f65bcb2472c3 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 82bf512eeb..e82cc10393 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,5 +1,7 @@ # Compaction +English | [中文](compaction.zh.md) + The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md new file mode 100644 index 0000000000..889cdba416 --- /dev/null +++ b/docs/core-data-structures/compaction.zh.md @@ -0,0 +1,57 @@ +# 压缩 + +[English](compaction.md) | 中文 + +压缩(compaction)seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),按 bash 模式拆分:接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(后端,如 [dsh-compact-basic](../../packages/compact/compact-basic))、消费方(一个 `/compact` 工具,暂缓)。压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。基于 tokenizer 或模板的后端是实现同一接口的兄弟包。与 bash 不同的是,该接口必然依赖 `dsh-session` 和 `dsh-llm`:它的动词定义在 `Session` 之上,输出是 `ContentBlock` 词汇(见[压缩能力 seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md))。 + +源码:[`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) + +## `compact/*` 会话事件 + +压缩通过声明合并为 [`SessionEventMap`](session.md) 扩展了三种事件类型。三者均为**仅日志**事件:它们记录压缩锁及其来源信息,永远不进入 surface。`SurfaceEventType` 被刻意**不**扩展(只有产生消息的事件才到达模型),因此摘要本身搭载在一条单独的 `user/message` 上,带有 `surfaceOp: { op: 'replace', start, end }`——唯一的 surface 变更。关于为何复用 `user/message` 是诚实的做法而非变通手段,见 RFC。 + +| 事件 | 载荷 | 作用 | +|---|---|---| +| `compact/start` | `{ turn }` | 获取日志记录的锁 | +| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, model, maxTokens? }` | 来源信息:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数量,以及摘要调用的信封(`model`,加上生效时的生成上限)——记录下来以便从日志 + 代码重建一次性请求(可重建性 RFC) | +| `compact/end` | `{ turn, error? }` | 释放锁(摘要生成抛出异常时设置 `error`) | + +锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、落入 `compact/summary` 来源记录和 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会变成一个可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而不是一个虚假声称压缩已完成的 `compact/end`。 + +这些变体在 `declare module '@deepseek-ai/dsh-session'` 块内合并,因此——与其他子页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 + +## `CompactionResult` + +一次成功的压缩返回给调用方的内容:三个追加的 `compact/*` 事件的 seq、摘要块,以及被遮蔽的范围/seq 加上估算 token 数量。 + +```ts type-equiv +interface CompactionResult { + /** The seq of the appended `compact/start` event. */ + startSeq: number + /** The seq of the appended `compact/summary` event. */ + summarySeq: number + /** The seq of the appended `compact/end` event. */ + endSeq: number + /** The summary content blocks produced by the backend. */ + summary: ContentBlock[] + /** + * The surface-boundary pair that was shadowed: the seqs of the first + * (`start`) and last (`end`) surface nodes of the replaced range. A + * surface-POSITION span, not a numeric seq interval — after a prior replace + * lands a fresh high-seq summary node at an older range's position, `start` + * can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the + * authoritative set of shadowed nodes, in surface order. + */ + shadowedRange: { start: number; end: number } + /** The seqs of all shadowed surface nodes, in surface order. */ + shadowedSeqs: number[] + /** Estimated token count of the shadowed content. */ + shadowedTokenCount: number +} +``` + +## 服务 + +`CompactService` 暴露 `compactIfNeeded(...)` 用于压力触发的压缩(不需要压缩时返回 `null`),以及 `compactRegion(...)` 用于对显式的 surface 闭区间执行压缩。pre-step 调用方提供 agent、完整提示词、会话前缀和 abort signal;实现必须将该 signal 转发给摘要生成。估算、保留策略、事件排序和摘要生成均为后端策略。 + +自动压缩在串行的 `agent/pre-step` 时运行,位于步骤和请求推导之前,因此它可以替换 surface 节点,同时将 trace 事件保持在步骤之外。区域边界保留工具调用/结果的配对,但不保留完整轮次,允许一个超大轮次中较早关闭的步骤被压缩。保留策略与失败处理的细节由 `dsh-compact-basic` 负责。 diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml new file mode 100644 index 0000000000..760bf4eda7 --- /dev/null +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +filesystem.md: 8bdc2323a0bf63588e01520926f093538fee4912 +filesystem.zh.md: 93ca9b26e054cadb40382391404573c95a1c8d05 diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 6c6dd3a130..8bdc2323a0 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,5 +1,7 @@ # Filesystem +English | [中文](filesystem.zh.md) + The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional version guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md new file mode 100644 index 0000000000..93ca9b26e0 --- /dev/null +++ b/docs/core-data-structures/filesystem.zh.md @@ -0,0 +1,149 @@ +# 文件系统 + +[English](filesystem.md) | 中文 + +可选的文件系统能力由四部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选版本守卫的原子文本操作,[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端,[dsh-fs-policy](../../packages/fs/fs-policy) 通过事件(而非服务)添加观测状态与新鲜度规则,[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop 主干之外;替换后端不会改变策略或工具 schema。 + +该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam(`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个在此之上*添加*策略的插件,通过裁决 `fs/*` waterfall(瀑布式事件)实现;移除它只会留下裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署预期同时加载 `dsh-fs-policy`,使默认行为为先读后写/编辑。 + +提供方源码:[`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) 与 [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)。策略源码:[`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts)。读取渲染源码:[`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts)。 + +## 目标标识与元数据(提供方 seam) + +每个操作首先将用户提供的路径解析为一个不透明的后端目标。消费方可以展示 `displayPath`,但不得解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。 + +```ts type-equiv +interface FsTarget { + targetKey: FsTargetKey + displayPath: string +} +``` + +后端拥有文件版本 token:即 write/edit 所守卫的新鲜度 token。策略插件存储它们用于陈旧检查;消费方不解释其含义。两个 id 都是品牌化的不透明字符串。 + +```ts type-equiv +type FsTargetKey = Branded<'FsTargetKey'> +``` + +```ts type-equiv +type FsVersion = Branded<'FsVersion'> +``` + +`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录/特殊文件,`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。 + +```ts type-equiv +interface FsInfo { + version: FsVersion + type: 'file' | 'directory' | 'other' + size?: number +} +``` + +`listDir` 以稳定的名称顺序返回直接子条目。每个条目携带子项的 basename、类型、已解析的目标,以及后端能廉价报告时的元数据。它不得读取文件内容,因此 `size` 仅适用于普通文件,`version` 来源于元数据。损坏或消失的子项可以作为 `other` 返回且不带元数据;列举或解析子项元数据时的权限或后端 I/O 失败会以 `FS_PERMISSION_DENIED` 或 `FS_IO_ERROR` 使整个列举失败。 + +```ts type-equiv +interface FsDirEntry { + name: string + type: 'file' | 'directory' | 'other' + target: FsTarget + version?: FsVersion + size?: number +} +``` + +## 写入与编辑守卫(提供方 seam) + +`writeText` 和 `editText` 都以可选方式接受版本守卫:省略即为无条件(裸提供方)变更,提供即为守卫。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 创建缺失的目标,若目标已存在则以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只携带两种守卫意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 + +```ts type-equiv +type FsWriteIntent = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } +``` + +```ts type-equiv +interface FsWriteOutcome { + operation: 'create' | 'update' + version: FsVersion + before: string | null + after: string +} +``` + +`editText` 是提供方级别的变更,而非在别处组合的 `read` 加 `write`。守卫模式下,它在字面匹配之前先验证预期版本(因此对陈旧内容的编辑报 `FS_STALE_VERSION`,而非对更新内容的匹配失败);无守卫模式下,它编辑当前内容。无论哪种路径,它都应用替换并原子写入——将匹配、行尾处理、陈旧检查与原子替换保持在同一个变更临界区内——且目标缺失时两种路径都报 `FS_STALE_VERSION`。 + +```ts type-equiv +interface FsEditRequest { + oldString: string + newString: string + replaceAll: boolean +} +``` + +```ts type-equiv +interface FsEditOutcome { + version: FsVersion + before: string + after: string +} +``` + +## fs 策略事件(提供方 seam 词汇) + +`dsh-fs` 拥有三个事件,由工具派发、策略插件监听,使发射方(`dsh-tool-fs`)和监听方(`dsh-fs-policy`)共享词汇而无需发射方依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/会话所有者结构。 + +`fs/write-intent` 和 `fs/edit-intent` 是**单槽决策 waterfall**:工具派发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全裁决而不调用 `next()`。该槽按注册顺序先到先得——策略插件占据该槽是部署约定,而非强制不变式。`fs/observed` 是一个即发即忘的记录事件,通过普通 `ctx.emit` 派发;其监听方必须是同步且仅有副作用的,因为工具不守卫该 emit——抛出异常的监听方会作为工具对一个已成功变更的 `isError` 结果暴露出来。生成的目录在 [events.md](../cordis-catalog/events.md) 展示确切签名。 + +## 执行上下文(策略插件) + +策略插件只需要足够的执行上下文来从 `fs/*` 事件携带的不透明 `object` actor 中窄化出观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 透传,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 + +```ts type-equiv +interface FsPolicyExec { + agent?: { + session?: object + } +} +``` + +## 读取结果(消费方 / 读取渲染) + +文本读取受行窗口、字节上限和后端限制约束。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接以 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取的执行器),而非策略插件。 + +```ts type-equiv +interface FileReadOutcome { + offset: number + lines: FileTextLine[] + totalLines: number + truncatedByBytes?: true +} +``` + +## 已观测文件状态(策略插件) + +已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap>`。条目存在**当且仅当**所有者已读取、写入或编辑过该目标(每次成功都 emit `fs/observed`),因此条目的存在本身就是先前观测的记录——没有单独的 `hasRead` 标志,也没有视图区分。所有者从事件 actor 派生(通常是 `exec.agent.session`),被视为不透明且从不读取。成功的 read/write/edit 会刷新该所有者对应的已记录版本;dispose 时丢弃全部数据(HMR 安全)。 + +## 错误分类体系(提供方 seam) + +文件系统失败使用稳定的 `FsErrorCode` 字符串,由 `FsError`(`HarnessError`)携带。工具注册表在错误结果上保留 `{ name, code }`,使重试、权限和 UI 层无需解析文本即可分支。 + +```ts type-equiv +type FsErrorCode = + | 'FS_NOT_FOUND' + | 'FS_NOT_DIRECTORY' + | 'FS_NOT_TEXT' + | 'FS_NOT_REGULAR_FILE' + | 'FS_PERMISSION_DENIED' + | 'FS_IO_ERROR' + | 'FS_STALE_VERSION' + | 'FS_NOT_OBSERVED' + | 'FS_AMBIGUOUS_EDIT' + | 'FS_EDIT_NOT_FOUND' + | 'FS_ABORTED' +``` + +`FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 和 `FS_IO_ERROR` 用于目录列举,分别区分目标存在但不是目录、列举被拒绝、以及意外的后端 I/O 失败。`FS_NOT_OBSERVED` 表示策略插件没有该所有者的先前观测记录(或 `createIfAbsent` 遇到了已存在的文件)。`FS_STALE_VERSION` 表示后端版本不再匹配已观测版本(或编辑遇到了缺失的目标)。新鲜度授权没有 partial/full 区分,因此不存在 `FS_PARTIAL_OBSERVATION`。 + +## 服务与插件 + +`FileSystem`(`ctx.fs`,抽象)拥有提供方原语:`resolve`、`stat`、`readText`、`streamText`、`listDir`、`writeText` 和 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门添加策略的插件:它裁决 write/edit intent waterfall(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读/写/编辑,派发 waterfall,并 emit 记录事件。生成的接线目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml new file mode 100644 index 0000000000..4227258611 --- /dev/null +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +llm-streaming.md: ffd276b4647be8d10afcab0fb3c3f6daad790d20 +llm-streaming.zh.md: 051d6f5d28059bf81ba38c348ea306c46e73d4e2 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index fff329310a..ffd276b464 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -1,5 +1,7 @@ # LLM Streaming +English | [中文](llm-streaming.zh.md) + The wire-level streaming vocabulary of [dsh-llm](../../packages/llm/llm). [core.md](core.md) introduces `StreamChunk`, `Message`, and `ContentBlock`; this page owns the full chunk protocol, the adapter contract every adapter must obey, and the shared assembler. Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md new file mode 100644 index 0000000000..051d6f5d28 --- /dev/null +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -0,0 +1,80 @@ +# LLM 流式输出 + +[English](llm-streaming.md) | 中文 + +[dsh-llm](../../packages/llm/llm) 的协议格式(wire format)级流式输出词汇。[core.md](core.md) 介绍了 `StreamChunk`、`Message` 与 `ContentBlock`;本页拥有完整的分片协议、每个适配器必须遵守的适配器契约(adapter contract),以及共享的 assembler。 + +源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +## `StreamChunk`:原始协议 + +一次流式响应会交错多种类型的块(文本、推理、多个工具调用)。`index` 将每个 delta 关联到对应的块;`block-end` 携带完整组装好的 `ContentBlock`,消费方无需自行重新组装 delta。这是一个**封闭的**可辨识联合类型:对 `type` 的 `switch` 以 `assertNever` 结尾,因此新增变体会在每个必须处理它的消费方处触发编译错误。 + +```ts type-equiv +type StreamChunk = + | { type: 'block-start'; index: number; blockType: ContentBlockType } + | { type: 'text-delta'; index: number; text: string } + | { type: 'reasoning-delta'; index: number; text: string } + | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string } + | { type: 'block-end'; index: number; block: ContentBlock } + | { type: 'usage'; usage: TokenUsage } + | { type: 'finish'; reason: FinishReason } +``` + +## 适配器契约 + +每个适配器必须遵守以下规则,每个消费方可以依赖它们: + +- **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 +- **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化。 +- **两条认可的错误路径。** 失败可以从 `stream()` 抛出异常(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted'}` 结束流(提供方带内错误,适用于无法在流中途抛出异常的适配器)。消费方必须同时处理*两种*情况。agent loop(智能体循环)将 finish-error/aborted 转化为轮次错误,绝不会为失败的步骤记录一条正常完成的 assistant 消息。 +- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试证明这一点(mock 服务器断言收到的 header,或库支持的适配器使用库的 header 钩子)。 + +这份契约正是两个适配器作为有意配对存在的原因:`dsh-llm-deepseek`(手写的 fetch/SSE(Server-Sent Events))与 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 访问同一端点)。两套独立的内部实现共享一份契约,正是它将协议钉死的方式:库支持的适配器无法在流中途抛异常,因此它行使了手写适配器可能不会走到的 finish-chunk 错误路径。 + +## `AppIdentity`:应用归属 + +每个适配器向提供方发送的静态公开应用身份([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 仅将其映射为标准 `User-Agent` header;本契约有意不支持 OpenRouter 特有的应用归属 header。默认的 `APP_IDENTITY` 从包(package)的 manifest(元数据清单)获取版本号;每个字段都是公开的产品事实,不含密钥、路径、会话 id 或用户标识符,且没有任何逐请求的值可以影响这些字段。设计依据见 [强制 `User-Agent` 归属](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 + +```ts type-equiv +interface AppIdentity { + product: string + version: string + url: string +} +``` + +## `TokenUsage` + +单次调用的 token 用量统计。各计数**互不重叠**:`inputTokens` 仅为未缓存的输入;缓存命中的输入单独报告,计费输入是三者之和。如果提供方将缓存命中合并到单一的 prompt 总量中(如 DeepSeek 的 `prompt_tokens`),适配器需将其减回去。 + +```ts type-equiv +interface TokenUsage { + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number +} +``` + +## `BlockAssembler` + +`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,负责将 `StreamChunk` 流折叠回 `ContentBlock` 序列与最终的 `Message`。agent loop 记录原始分片(保证回放保真度),同时将相同的分片送入 assembler;这样权威日志保留了 token 级别的细节,而派生的消息可以确定性地重建。需要组装结果但不想重新实现折叠逻辑的消费方使用它。 + +## seam + +`LlmAdapter` 是提供方 seam:继承它、实现 `stream()`、通过 `ctx.llm.registerAdapter(models, adapter)` 注册。`block-start`/`block-end` 的 `index` 关联加上 assembler,意味着适配器只需发出格式正确的分片,块的重新组装不是各适配器自己的问题。消费方接口(`ctx.llm.stream()`)与 `llm/stream` waterfall(瀑布式事件)在 [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm) 中描述。 + +`ContentBlockType`(`index` 关联的块所携带的键集合)派生自 `ContentBlockMap`: + +```ts type-equiv +interface ContentBlockMap { + 'text': TextBlock + 'reasoning': ReasoningBlock + 'tool-call': ToolCallBlock + 'tool-result': ToolResultBlock +} +``` + +块接口详见 [core.md § Content blocks and messages](core.md#content-blocks-and-messages)。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml new file mode 100644 index 0000000000..9f69617b7f --- /dev/null +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +persistence.md: ce7a21a5613da903a9bddd339e122bf9f899d2bd +persistence.zh.md: 07d54895ef59b99dca47142e3fde16e6d7d0d1b3 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..ce7a21a561 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -1,5 +1,7 @@ # Session Persistence +English | [中文](persistence.zh.md) + The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md new file mode 100644 index 0000000000..07d54895ef --- /dev/null +++ b/docs/core-data-structures/persistence.zh.md @@ -0,0 +1,90 @@ +# 会话持久化 + +[English](persistence.md) | 中文 + +事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述该日志如何被持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一起存储的元数据头。日志所承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐一列出。 + +该 seam 是教科书式的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在既有的 `SessionEvent` 之上定义 create/append/load/list——**没有平行的持久化类型**——以及两个可互换的后端,它们通过同一套 `runPersistenceContract` 测试。见 [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md)。 + +## flush 检查点 + +`session/event` 是一个*同步*通知;持久化插件对其进行缓冲(write-behind),并在 agent loop 于每个轮次结束时触发的 `session/flush` 检查点处排空缓冲区。flush 使用 `ctx.parallel`(被 await):一个轮次的事件在下一个轮次开始前被持久提交,轮次边界即提交边界。flush 失败时通过 `agent/error` 和 logger 报告,而非作为会话事件(那样会落在提交边界之后),因此后端保留其缓冲事件等待下一次 flush。 + +## 崩溃恢复保留被中断的轮次 + +后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 而没有对应的 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次封闭不变式完好。`interrupted` 是唯一一个 agent loop 不会自行发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 + +## `SessionHeader`:日志旁的元数据 + +每个会话的元数据与事件日志**分开**存储:格式版本、cwd、血缘关系和 seed 边界属于存储关注点而非对话事件,因此它们不在 `SessionEventMap` 中,也不会进入 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。 + +源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) + +```ts type-equiv +interface SessionHeader { + /** + * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the + * session is created. A persistence backend rejects any other version on load + * (no migration — see the constant). + */ + readonly version: number + /** The session's id (mirrors the {@link Session}'s id). */ + readonly id: SessionId + /** Unix epoch milliseconds when the session was created. */ + readonly createdAt: number + /** Absolute working directory the session was created in (if any). */ + readonly cwd?: string + /** The session this one was forked from (seed lineage), if any. */ + readonly parentSession?: SessionId + /** + * How many leading events were INHERITED via a seed rather than produced by + * this session — the seed boundary. Set when a fork seeds a child with a + * prefix of the parent's log (= the seeded prefix length); absent/0 means the + * session produced all its own events. Persisted so a reload reconstructs the + * boundary instead of re-deriving it from the full stored log, and so a replay + * harness can skip the inherited prefix when deriving the child's OWN script + * (the seeded events are the parent's, not this child's model calls). + */ + readonly seedLength?: number +} +``` + +## `CreateSessionOptions`:seed 与元数据 + +通过 store 创建 `Session` 时接受 `seed`(回放/fork 一个已有事件日志)和 `meta`(store 折叠进 `SessionHeader` 的存储级字段)。store 填充 `version`/`id` 并为 `createdAt` 设默认值;调用方提供经过校验的绝对路径 `cwd`、`parentSession` 血缘、`seedLength` seed 边界,以及仅在重建持久化会话时提供的原始 `createdAt` 以保留它。 + +```ts type-equiv +interface CreateSessionOptions { + /** Events to seed the new session with (replay/fork). */ + readonly seed?: readonly SessionEvent[] + /** + * Creation metadata. The store fills in `version`/`id` and defaults + * `createdAt` to now; the caller supplies the storage-level fields (validated + * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and + * — when reconstructing a persisted session — the original `createdAt` to + * preserve it). + * + * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction + * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full + * length, not the original boundary — the caller must pass the persisted + * boundary back. A fresh fork passes its actual seeded-prefix length. + */ + readonly meta?: { + readonly cwd?: string + readonly parentSession?: SessionId + readonly createdAt?: number + readonly seedLength?: number + } +} +``` + +因此,回放/fork 是 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 是 `ctx.agents.resume({ resumeSessionId })`。 + +## 后端 + +两个后端实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 之上的 create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 真正与后端无关: + +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**:每个会话一个仅追加的 JSONL 日志,具备崩溃安全的原子写入、上述中断轮次崩溃恢复,以及读取/回放路径。 +- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包括可选的 surface 元数据),因此没有需要保持同步的平行持久化 schema。 + +多个后端共享同一个磁盘会话时,通过[共享持久化写协调器](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml new file mode 100644 index 0000000000..67bc36207d --- /dev/null +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +sandbox.md: be8e3cd60681077ff5036915fd99520fe9685140 +sandbox.zh.md: 2e9e5aa9a65e636167e45900f169ed6da5219c24 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 6b7b212373..be8e3cd606 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -1,5 +1,7 @@ # Process Sandbox +English | [中文](sandbox.zh.md) + The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies the Linux bwrap/Landlock and macOS Seatbelt backends; [dsh-bash-sandbox](../../packages/bash/bash-sandbox) is the first consumer. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`. Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md new file mode 100644 index 0000000000..2e9e5aa9a6 --- /dev/null +++ b/docs/core-data-structures/sandbox.zh.md @@ -0,0 +1,84 @@ +# 进程沙箱 + +[English](sandbox.md) | 中文 + +[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将同世界子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 与远程执行是整体能力 seam 的兄弟实现,而非 `ctx.sandbox` 的提供方。 + +源码:[`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) + +## 模式与强制 + +`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝写入(必需的 `/dev/null` sink 除外);`workspace-write` 允许在工作区根目录与后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此词汇范围内。 + +```ts type-equiv +type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' +``` + +只有前两种模式可以发送给提供方。`danger-full-access` 消费方直接 spawn 原始 argv,不调用 `ctx.sandbox`。 + +```ts type-equiv +type ConfinedSandboxMode = Exclude +``` + +强制级别是一个报告事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控了一个子集,因此要求绝对承诺的消费方必须拒绝或向上暴露这一区别。 + +```ts type-equiv +type SandboxEnforcement = 'full' | 'partial' +``` + +## 逐调用策略 + +策略在每次调用时完全解析并随调用携带。这使得并发消费方和一次性升级重试可以向同一个提供方请求不同的边界,而无需修改提供方状态。 + +```ts type-equiv +interface SandboxPolicy { + /** The file-effect mode this execution runs under. */ + mode: ConfinedSandboxMode + /** Absolute root directory `workspace-write` may write under. */ + workspaceRoot: string +} +``` + +## 包装后的 argv 与分类方言 + +`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制事实和两组正交的 stderr 方言。`denialSignatures` 标识沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureSignatures` 标识沙箱运行器在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障暴露,而非普通任务失败。 + +```ts type-equiv +interface ConfinedArgv { + /** The wrapped argv (runner, profile, separator, then the caller's argv). */ + argv: string[] + /** How completely the selected backend enforces the policy's file effects. */ + enforcement: SandboxEnforcement + /** + * The selected backend's denial DIALECT: the case-insensitive stderr + * substrings a file effect denied by THIS backend produces (EROFS text + * under bwrap's read-only binds, EACCES under Landlock, EPERM under + * Seatbelt). A consumer that infers denials from a failed run's stderr + * matches against exactly these rather than a cross-backend union — the + * union claims denials a given backend never produces. + */ + denialSignatures: readonly string[] + /** + * How the RUNNER ITSELF failing identifies itself: case-insensitive stderr + * substrings produced when the sandbox binary is missing, refuses its + * profile, or fails closed before exec'ing the command (`bwrap: `, + * `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own + * error prefix and the shell's runner-not-found message). ORTHOGONAL to + * {@link denialSignatures}: a denial is the confined COMMAND being blocked + * (the sandbox working as designed); a runner failure means the command + * NEVER RAN and must surface as a sandbox failure, not a task failure — + * consumers check these signatures FIRST (a runner's own error text may + * contain denial words, e.g. an unopenable grant root reporting + * `Permission denied`). + */ + runnerFailureSignatures: readonly string[] +} +``` + +运维人员配置的本地运行器必须为自身的 pre-exec 拒绝方言提供至少一条 `runnerFailureSignatures` 条目;提供方会自动添加外层 shell 的 missing 和 unexecutable 形式。这使得可执行的自定义运行器拒绝其 profile 的情况与被包装命令以相同状态码退出的情况可以区分开来。 + +## 提供方与 fail-closed 错误 + +`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的运行器也可能在执行时 fail-closed,此时其失败签名承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 + +提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 diff --git a/docs/core-data-structures/scope.i18n.yaml b/docs/core-data-structures/scope.i18n.yaml new file mode 100644 index 0000000000..45f21bc10d --- /dev/null +++ b/docs/core-data-structures/scope.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +scope.md: f95594329ee9ac83da2efcc31df377c53e64331a +scope.zh.md: 80b2a7355e0499fcccf0e218c57f395252416cbd diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md index 66d5f40de9..f95594329e 100644 --- a/docs/core-data-structures/scope.md +++ b/docs/core-data-structures/scope.md @@ -1,5 +1,7 @@ # Scoped Registration +English | [中文](scope.zh.md) + The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics. Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts). diff --git a/docs/core-data-structures/scope.zh.md b/docs/core-data-structures/scope.zh.md new file mode 100644 index 0000000000..80b2a7355e --- /dev/null +++ b/docs/core-data-structures/scope.zh.md @@ -0,0 +1,33 @@ +# 作用域注册 + +[English](scope.md) | 中文 + +[scope 包](../../packages/core/scope)提供身份标识与载体词汇,使一个注册上下文同时表达「按 agent 可见」和「共享生命周期所有权」两层含义。它是一个库级原语,而非 Cordis 服务;[agent-scope 运行时设计 RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) 拥有实现动机,包的 [README](../../packages/core/scope/README.md) 拥有可调用 API 与过滤语义。 + +源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts)。 + +## 身份标识与分发载体 + +`ScopeKey` 是一个不透明的对象标识。已交付的 agent loop 使用活跃的 `Agent` 对象作为自身的 key,但该原语从不检视该对象。 + +```ts type-equiv +type ScopeKey = object +``` + +`Scoped` 是 `scopeTarget(base, key)` 返回的不透明路由接收者上的编译期品牌类型。经作用域过滤的事件声明要求以此载体作为其 `this` 类型,而真正的事件主体仍作为显式参数传递。 + +```ts type-equiv +type Scoped = object & { readonly [ScopedBrand]: T } +``` + +## 拥有所有权的注册上下文 + +`Scope` 将带标签的注册上下文与两个拆卸面配对。`rawDispose` 保留有序组合副作用所需的精确 Cordis disposer 标识;`dispose()` 是面向直接调用方和竞争调用方的公共共享静默边界。 + +```ts type-equiv +interface Scope { + ctx: Context + rawDispose: () => Promise | void + dispose(): Promise +} +``` diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml new file mode 100644 index 0000000000..53170b4ef6 --- /dev/null +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +session-query.md: 444f2bb2256a43df7bd8521dfe234f771eec7181 +session-query.zh.md: 4d00e5fa310d82c6099ab4f5255f09f9e253c150 diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index ded8ca3f7e..444f2bb225 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -1,5 +1,7 @@ # Session Query +English | [中文](session-query.zh.md) + Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase. Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md new file mode 100644 index 0000000000..4d00e5fa31 --- /dev/null +++ b/docs/core-data-structures/session-query.zh.md @@ -0,0 +1,71 @@ +# Session Query + +[English](session-query.md) | 中文 + +对实时优先的逻辑会话语料库进行精确读取。[包(package)契约](../../packages/session-query/session-query)定义了源优先级、动态可选持久化、克隆、surface 分类、有界窗口与类型化错误。全文搜索是一个独立提议的 SQLite 阶段。 + +源码:[`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) + +## 逻辑记录 + +`SessionRecord` 由跨语料库列表返回。它独立于克隆的实时优先 header 暴露源可用性。`SessionEventRecord` 是一个轻量的原始日志投影;分类使用与 model-history 推导相同的 `foldSurface()` 状态转换。 + +```ts type-equiv +export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' +``` + +```ts type-equiv +export interface SessionRecord { + header: SessionHeader + live: boolean + persisted: boolean +} +``` + +```ts type-equiv +export interface SessionEventRecord { + sessionId: SessionId + seq: number + type: SessionEventType + time: number + surface: SessionEventSurface +} +``` + +## 有界事件读取 + +请求指定一个原始 seq 以及可选的前后邻近数量。结果携带 `SessionHeader` 而非可用性标志,使已知的实时目标可以独立于持久化健康状态。 + +```ts type-equiv +export interface SessionEventReadRequest { + sessionId: SessionId + seq: number + before?: number + after?: number +} +``` + +```ts type-equiv +export interface SessionEventWindow { + session: SessionHeader + target: SessionEvent + events: SessionEvent[] + startSeq: number + endSeq: number +} +``` + +## 错误 + +封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端失败与源元数据矛盾。 + +```ts type-equiv +export type SessionQueryErrorCode = + | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_SURFACE' + | 'SESSION_QUERY_INVALID_WINDOW' + | 'SESSION_QUERY_PERSISTENCE_FAILED' + | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_SOURCE_CONFLICT' +``` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml new file mode 100644 index 0000000000..9910652ad0 --- /dev/null +++ b/docs/core-data-structures/session.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +session.md: 796abebbc31a54c7c341028cf0a09031ae59cd78 +session.zh.md: a2ff9319af1425792702502e12bd287d7f3ca805 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d8292c49b3..796abebbc3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -1,5 +1,7 @@ # Sessions +English | [中文](session.zh.md) + The in-memory, event-sourced model of [dsh-session](../../packages/core/session). A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth for an agent's whole interaction history. The LLM message history is *derived* from the log, never stored separately; replay is re-derivation from the same events. How the log is made **durable** (the persistence seam, backends, crash recovery) is the sibling concern on [persistence.md](persistence.md). Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md new file mode 100644 index 0000000000..a2ff9319af --- /dev/null +++ b/docs/core-data-structures/session.zh.md @@ -0,0 +1,312 @@ +# 会话 + +[English](session.md) | 中文 + +[dsh-session](../../packages/core/session) 的内存事件溯源模型。`Session` 是一份由类型化 `SessionEvent` 组成的**仅追加日志**,是 agent(智能体)整个交互历史的唯一真源。LLM(大语言模型)消息历史从日志*派生*而来,从不单独存储;回放即从同一组事件重新派生。日志如何实现**持久化**(持久化 seam、后端、崩溃恢复)是兄弟文档 [persistence.md](persistence.md) 的关注点。 + +源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) + +## `SessionEventMap`:事件词汇 + +仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[压缩(compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并的),包括其 payload、surface 标记和声明位置。 + +```ts type-equiv +interface SessionEventMap { + 'turn/start': { turn: number; trigger: TurnTrigger } + 'turn/end': { turn: number; reason: TurnEndReason } + 'step/start': { turn: number; step: number } + 'step/end': { turn: number; step: number } + /** A user-visible prompt (queued message drained at turn start). */ + 'user/message': { content: ContentBlock[]; source: MessageSource } + /** + * A queued prompt an `agent/prompt-submit` listener VETOED — the durable + * record of a blocked prompt and why. Appended in place of the `user/message` + * the prompt would have become, so the block survives replay even in a MIXED + * batch where another queued prompt is allowed (there the turn does not end + * `rejected`, so the boundary reason alone would not preserve it). `content` + * is the original prompt the listener rejected; `reason` is the veto text + * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a + * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + */ + 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } + /** + * In-session context injection (file-change notices, subdir AGENTS.md, + * skill content, cron notifications, …). Rendered into the derived history + * as tagged synthetic context — NOT a user prompt. + */ + 'context/message': { content: ContentBlock[]; source: MessageSource } + /** Raw stream chunk — token-level replay fidelity. */ + 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } + /** + * Assembled assistant message for one step (derived history uses this). + * Carries the step's `usage` when the adapter reported token accounting, so + * the model output and its accounting travel together (there is no separate + * usage record). `usage` is absent when the adapter reported none. + */ + 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } + 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + /** Steering content injected between steps of a running turn. */ + 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } + /** + * The agent's whole todo list, carried as a full snapshot and replaced + * wholesale on each write — the current list is the most recent `todo/write` + * (last-write-wins on replay, no fold). Appended by an owning agent via + * `session.append('todo/write', { todos })`. + * + * NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches + * `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — + * it is durable, replayable UI state, distinct from the conversation history. + * It is a `SessionEventMap` member riding the existing `session/event` emit, + * not a first-class Cordis `interface Events` notification, so it has no + * cordis-catalog row. + */ + 'todo/write': { todos: TodoItem[] } + /** + * Full snapshot of the {@link EpochHeader} the NEXT request is built under, + * with the {@link RequestHeaderReason} it was recorded whole. Appended by + * the loop inside the step, before dispatch, on a loop instance's first + * request-building step (`'initial'`/`'resume'`) or when a delta failed its + * round-trip guard (`'fallback'`); always records what the request actually + * used, post-`agent/request`. Anchors the header fold: reconstruction reads + * the latest snapshot and applies the deltas after it. NOT a + * {@link SurfaceEventType}: it produces no LLM message — it is the request + * envelope, logged so every request is a pure function of the session log + * (the reconstructability RFC). + */ + 'request/header': { header: EpochHeader; reason: RequestHeaderReason } + /** + * Amendment to the folded {@link EpochHeader}: system line-trim, name-keyed + * tools delta, whole replacement config, or whole replacement session + * prefix (an EMPTY array encodes the transition to "none"). The + * writer verifies `applyHeaderDelta(previous, delta)` reproduces the new + * header exactly and falls back to a `'fallback'` `request/header` snapshot + * when it cannot, so a logged delta ALWAYS round-trips. NOT a + * {@link SurfaceEventType}. + */ + 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } +} +``` + +### `TodoItem`:一条待办项 + +`todo/write` 事件全量快照的单元。刻意保持最小化:一行 `content` 加一个三态 `status`(无 id、无优先级、无 `activeForm`)。列表在每次写入时整体替换,因此条目不需要稳定标识;三态 status 恰好对应 ACP 的 `PlanEntryStatus`,UI 桥接层可以将 todo 列表 1:1 映射到 ACP `plan`(ACP 额外要求的 priority 由桥接层合成)。见 [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md)。 + +```ts type-equiv +export interface TodoItem { + content: string + status: 'pending' | 'in_progress' | 'completed' +} +``` + +### 请求头事件:`request/header` 与 `request/header-delta` + +请求信封(`EpochHeader`:调用配置 + 渲染后的系统提示词 + 组装好的工具 schema + 会话前缀)是被记录到日志中的会话状态,因此每次对话请求都是日志的纯函数(可重建性 RFC)。`request/header` 快照(reason 为 `'initial' | 'resume' | 'fallback'`)在对话创建、进程边界和 delta 编码回退时锚定折叠点;`request/header-delta` 事件在运行中修正它。`foldRequestHeader(events)` 可重建任何请求构建时所用的 header;写入器在记录每个 delta 前都会做往返验证,因此格式良好的日志总能折叠。两者都不是 `SurfaceEventType`,不产生 LLM 消息。 + +```ts type-equiv +export interface EpochHeader { + /** The conversation's call configuration (model + sampling scalars). */ + config: LlmCallConfig + /** Rendered system prompt text; absent for a system-less request. */ + system?: string + /** Assembled tool schemas; absent for a tool-less request. */ + tools?: ToolSchema[] + /** + * The session prefix: request-only messages sent BEFORE the entire derived + * history (the `agent/session-prefix` waterfall's product, composed once + * per loop instance and reused for every request it sends). Not session + * history — `deriveMessages()` never returns it — so the header is its + * only durable record; absent when the instance composed none. + */ + messagePrefix?: Message[] +} +``` + +规范形式:空的系统提示词、空的工具列表和空的会话前缀表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix` + 派生历史);每个 agent loop(智能体循环)实例组装一次,由该实例的快照锚定,因此循环实际上不会产生 prefix delta。delta 分支(数组整体替换,空数组编码回到缺失状态的转换)为编解码完备性而存在。其他 delta payload(`SystemDelta`:公共前缀/后缀行裁剪;`ToolsDelta`:按名称键控的增/删/改)与事件一起定义在 [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) 中。 + +## `SessionEvent`:一条日志条目 + +基于 `type` 的正规可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 可以收窄 `event.data` 而无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 + +```ts type-equiv +type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +`SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。 + +## Surface 类型 + +五种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`)携带 surface 元数据,声明它们如何加入派生的 surface 链表。见[会话 surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md)。 + +### `SurfaceEventType`:产生消息的事件类型子集 + +```ts type-equiv +export type SurfaceEventType = + | 'user/message' + | 'assistant/message' + | 'tool/result' + | 'context/message' + | 'steering/message' +``` + +### `SurfaceOp`:事件如何进入 surface + +```ts type-equiv +export type SurfaceOp = + | 'append' + | { op: 'replace'; start: number; end: number } +``` + +`'append'` 是正常的尾部追加路径。`replace` 遮蔽从 `start` 到 `end`(含两端,两者必须是有效的 surface 节点 seq;`start === end` 替换单个节点)的 surface 节点,并在其位置插入新节点。 + +### `SurfaceIntent`:`session.append()` 的参数 + +```ts type-equiv +export interface SurfaceIntent { + surfaceOp: SurfaceOp + sourceEventSeqs?: number[] +} +``` + +`SurfaceEventType` 事件必须提供此参数:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。 + +### `SurfaceNode`:surface 链表中的一个节点 + +```ts type-equiv +export interface SurfaceNode { + seq: number + prev: number | null + next: number | null +} +``` + +### `SurfaceFoldReplacement` 与 `SurfaceFoldResult`:完整的 surface 回放 + +`foldSurface(events)` 返回当前分离的节点,以及每个声明的替换范围实际遮蔽的节点 seq。`SurfaceManager` 对其增量缓存使用相同的转换函数。 + +```ts type-equiv +export interface SurfaceFoldReplacement { + seq: number + start: number + end: number + shadowedSeqs: number[] +} +``` + +```ts type-equiv +export interface SurfaceFoldResult { + nodes: SurfaceNode[] + replacements: SurfaceFoldReplacement[] +} +``` + +## 派生历史:`deriveMessages()` 与 `deriveEventMessage()` + +`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,其中的消息是共享的深度冻结对象,因此无法通过投影来修改已记录的历史)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: + +- `user/message` → 一条 user 消息。 +- `assistant/message` → 一条 assistant 消息。原始 `assistant/chunk` 事件是回放/UI 数据,在派生中被**跳过**(组装后的消息才是权威的)。**空内容**的 `assistant/message` 也被跳过:max-tokens 截断且无内容的步骤仍会记录 `assistant/message` 以承载其 `usage`,但无内容的 assistant 轮次不得进入提供方的 transcript(文本记录)。 +- `tool/result` → 一条携带 `tool-result` 块的 user 消息。 +- `context/message`、`steering/message` → 按时间顺序插入的 user 角色消息,包裹在标签信封中(``)。这是"系统提醒"模式;模型通过信封将它们与真实提示词区分开来。 + +其他一切(`turn/*`、`step/*`)是结构性的,不投影为消息。token 用量在 `assistant/message.usage` 上观察(即产生它的那个步骤);操作错误的步骤编号在 `turn/end.reason` 中(`kind: 'error'` 时)。 + +## 活跃会话 fork API + +`ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: + +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取源事件直到(含)`boundary` seq(默认:当前最后一个事件),要求 boundary 事件为 `turn/end`,然后创建一个活跃的子会话,包含深克隆的种子事件和子元数据(`parentSession`、`seedLength` 以及继承的 `cwd`)。 + +显式 `boundary` 允许调用方从之前完成的轮次 fork,即使源有更新的事件或一个未关闭的当前轮次。API 拒绝非 `turn/end` 的 boundary,而不是静默裁剪。更广泛的轮次封闭性检查保留在既有的 `dsh-invariants` 插件和持久化修复路径中,而非在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀裁剪逻辑,因为工具时委托通常在父轮次打开时启动;普通的会话分支应显式指定所请求的 boundary。 + +## 轮次的触发原因:`TurnTriggerMap` + +```ts type-equiv +interface TurnTriggerMap { + message: { kind: 'message'; source: MessageSource } + /** + * An out-of-band context injection (`agent.inject()`) made while the agent + * was idle. The loop wraps the injected `context/message` in a one-shot turn + * (`turn/start` → `context/message` → `turn/end`) so every event in the log + * stays turn-enclosed — the durability/replay boundary is the turn, and a + * bare event between turns would otherwise be indistinguishable from a crash + * tail on reload. + */ + injection: { kind: 'injection'; source: MessageSource } +} +``` + +## 轮次的结束原因:`TurnEndReasonMap` + +```ts type-equiv +interface TurnEndReasonMap { + completed: { kind: 'completed' } + aborted: { kind: 'aborted'; reason?: string } + /** + * The turn failed: a step threw or the model reported a failure. `step` is the + * step number the failure occurred on (the operational error's location — the + * single durable record of an in-turn failure; live diagnostics also fire via + * `agent/error`). `code` is the error's code when one was attached. + */ + error: { kind: 'error'; step: number; message: string; code?: string } + disposed: { kind: 'disposed' } + 'max-tokens': { kind: 'max-tokens' } + /** + * The turn's entire prompt batch was BLOCKED before any step ran — every + * drained queued message was vetoed by an `agent/prompt-submit` listener (a + * hook). The turn still opened (so the boundary stays balanced and the block + * is a durable in-turn fact), but ran zero steps. `reason` carries the block + * message from the vetoing decision. Distinct from `aborted` (a user-driven + * cancel) and `error` (a failure): the prompt was rejected by policy, not + * interrupted or broken. A UI renders it as "prompt blocked by hook". + */ + rejected: { kind: 'rejected'; reason: string } + /** + * The turn never ended on its own: the process crashed mid-turn and a + * persistence backend later closed the orphaned (open) turn on reload so the + * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no + * loop ever emits this. Its events are real (they were durably appended before + * the crash) and are PRESERVED, not discarded: a single turn can be huge in a + * long-horizon task (many steps, large tool output), so truncating it would + * lose real work. The marker records that the turn was cut short, not that the + * model completed it. See the session-persistence RFC. + */ + interrupted: { kind: 'interrupted' } +} +``` + +`max-tokens` 对应同名的模型调用 `FinishReason`:轮次中任何一个步骤出现 `max-tokens`,整个轮次就以 `max-tokens` 结束而非 `completed`(截断事实优先于后续续写),消费方可以区分正常停止与被截断的情况。但这仅相对于 `completed` 而言:`disposed`/`aborted`/`error` 结果优先级更高。`rejected` 是一个零步骤轮次,其整个提示词批次被 `agent/prompt-submit` 钩子阻止(ACP 桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不由循环发出的 reason,由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 + +## 轮次封闭不变式 + +每个会话事件都位于一个轮次**内部**(在 `turn/start` 与其对应的 `turn/end` 之间)。循环在 `turn/start` *之后*追加排队的 `user/message` 事件;空闲时的 `agent.inject()` 将其 `context/message` 包裹在一个一次性的 `injection` 轮次中。这使得轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为中断崩溃的尾部,而不会误丢合法记录的轮次间上下文。`dsh-invariants` 插件在开发环境中强制执行此不变式(在未打开的轮次中追加消息事件会抛出异常)。见[轮次封闭不变式 RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 + +## 插件贡献的仅日志事件 + +插件可以通过 declaration merging 向 `SessionEventMap` 添加额外类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个已打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 和溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 + +钩子桥接的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在循环的已打开轮次内触发,因此其 `hook/*` 记录天然满足轮次封闭。`SessionStart` 没有 `hook/*` 记录(其注入的 `context/message` 就是持久证据),因为它没有可以容纳记录的已打开轮次(见[钩子桥接 RFC](../rfc/implemented/feature/2026-06-30-hook-bridges.md))。 + +## 持久性契约 + +持久化后端所依赖的契约:持久日志逐字保存每个事件,**包括** `assistant/chunk`。`seq` 必须保持连续,因此不能从规范日志中过滤掉 chunk。所有 `event.data` 必须是 JSON 可序列化的;`Session.append` 在源头强制执行此约束(对不可序列化的数据抛出异常),因此坏事件永远不会进入日志,`session.events` 始终等于后端可以持久化的内容。添加一个携带不可序列化数据的事件类型,或破坏不变式插件所检查的轮次/步骤嵌套,都是对磁盘格式的破坏性变更。 + +消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml new file mode 100644 index 0000000000..8958dbe359 --- /dev/null +++ b/docs/core-data-structures/skills.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +skills.md: b0a847cec05651b63e63de96423170a0fd7ca2a9 +skills.zh.md: db196d2058cd1ede2ef7c9fda7668720ea2824b9 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 93189ba2cb..b0a847cec0 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -1,5 +1,7 @@ # Skills +English | [中文](skills.zh.md) + The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the session-prefix catalog and model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md new file mode 100644 index 0000000000..db196d2058 --- /dev/null +++ b/docs/core-data-structures/skills.zh.md @@ -0,0 +1,120 @@ +# Skills + +[English](skills.md) | 中文 + +[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。Skill 是可选指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 + +源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 + +## 提供方注册表 + +`ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化和发现属于 await 的 `list()`。提供方对象、选项和候选项以只读方式借用,语义字段会被校验。 + +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败。 + +```ts type-equiv +interface SkillProvider { + readonly name: string + readonly list: (options: SkillLookupOptions) => Promise + readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise +} +``` + +## 本地发现优先级 + +内置的本地提供方按 rank 顺序扫描根目录: + +| Rank | Source | Root | +|---|---|---| +| 100 | `project-dsh` | `/.dsh/skills` | +| 200 | `project-agents` | `/.agents/skills` | +| 300 | `custom` | `Config.customSkillDirs` | +| 400 | `user-dsh` | `/skills` | +| 500 | `user-agents` | `/skills` | + +项目根目录是最近的包含 `.git` 的祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 遍历通过文件系统服务探测 `.git`,使远程或沙箱化的工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 + +## Skill 标识 + +Skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`/SKILL.md`)和扁平 Markdown 文件(`.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 + +```ts type-equiv +type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +``` + +## 摘要、候选项与完整定义 + +`SkillSummary` 是注册表面向模型可调用的摘要形状。消费方自行选择渲染哪些字段;会话目录仅使用 `name` 和 `description`,从不使用正文或绝对文件路径。`disableModelInvocation` 将 skill 从模型列表中隐藏,但允许受信代码按名称加载。 + +```ts type-equiv +interface SkillSummary { + readonly name: string + readonly description: string + readonly whenToUse?: string + readonly disableModelInvocation?: boolean + readonly source: SkillSource + readonly provider: string + readonly resourceBase?: SkillResourceBase +} +``` + +`SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时回传。 + +```ts type-equiv +interface SkillCandidate extends SkillSummary { + readonly rank: number + readonly locator: unknown + readonly path?: string + readonly metadata?: Readonly> +} +``` + +`SkillDefinition` 是 `ctx.skills.get()` 返回的完整解析结果,供 `skill` 工具使用。`resourceBase` 告诉工具如何为本地、URL 或提供方管理的 skill 渲染相对资源指引。 + +```ts type-equiv +type SkillResourceBase = + | { readonly kind: 'directory'; readonly path: string } + | { readonly kind: 'url'; readonly url: string } + | { readonly kind: 'opaque'; readonly description: string } +``` + +```ts type-equiv +interface SkillDefinition extends SkillSummary { + readonly content: string + readonly path?: string + readonly metadata?: Readonly> +} +``` + +运行时 skill 使用相同的完整形状,参与相同的先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 + +```ts type-equiv +type SkillRegistration = Omit & { + readonly provider?: string +} +``` + +## 查找与配置 + +Skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方工作。提供方接收同一个只读选项对象,用于缓存标识和加载。取消在目录选择前后(包括缓存命中)都会检查,并同时竞争发现和完整定义加载。如果找不到 git 根目录,本地提供方将提供的 cwd 本身视为项目根目录。 + +```ts type-equiv +interface SkillLookupOptions { + readonly cwd?: string | undefined + readonly signal?: AbortSignal | undefined +} +``` + +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 和 `customSkillDirs`)。消费方拥有其目录描述上限。 + +```ts type-equiv +interface Config { + readonly collectCacheMaxEntries?: number +} +``` + +## 会话目录与工具契约 + +`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一个 user-role 的 ``。目录包含按名称排序的 skill `name` 和经过规范化、XML 转义的 `description`;不包含正文、路径、来源、提供方和路由提示。前缀发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方配置的描述上限,默认 `500`,整数最小值 `3`。其仅限请求、记录于 header 的生命周期由 [session-prefix RFC](../rfc/implemented/feature/2026-07-07-session-prefix.md) 定义。 + +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用 agent 的 cwd 加载完整定义,将未解决的 skill 报告为未知或不再可用,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml new file mode 100644 index 0000000000..b859346be3 --- /dev/null +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +subagent.md: eb9160abaee26969aecdc533fb9fd56fae18b7fa +subagent.zh.md: f29ebcd50b5d3a6d961583e381b81eb88ab95823 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 28093165bd..eb9160abae 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -1,5 +1,7 @@ # Subagent +English | [中文](subagent.zh.md) + The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent RFC](../rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md new file mode 100644 index 0000000000..f29ebcd50b --- /dev/null +++ b/docs/core-data-structures/subagent.zh.md @@ -0,0 +1,101 @@ +# Subagent + +[English](subagent.md) | 中文 + +subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 类似,它是**一项可选能力**,不属于 agent loop(智能体循环)的主干,因此其词汇定义在这里而非 [core.md](core.md)。但它在一个维度上与其他所有 seam 不同:**多个提供方实现在同一个上下文中共存**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM 适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 + +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现是兄弟包(`dsh-subagent-spawn`、`-fork`、`-acp`);面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计动机见 [subagent RFC](../rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)。 + +源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) + +## 两类能力,两种发现方式 + +提供方通过一个静态描述符公布其**启动时**特性,服务在运行实例存在之前就会检查它;如果请求需要提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会接受后静默忽略。**运行时**特性(steering(中途引导)、resume)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法:方法的存在本身即为能力,TypeScript 的类型收窄就是发现机制。 + +```ts type-equiv +interface SubagentCapabilities { + readonly outputSchema: boolean + readonly depthLimit: boolean + readonly toolFilter: boolean + readonly persona: boolean +} +``` + +## 启动请求 + +工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前对照指定提供方进行校验。必填的 `parent` 提供会话 cwd、血统链和委派深度。可选的 output schema、depth、tool filter 和 persona 需要对应的能力标志位。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 限定在子 agent 创建阶段,并通过一个强制捕获工具实现所支持的 object-rooted schema。 + +```ts type-equiv +interface SubagentStartRequest { + readonly prompt: ContentBlock[] + readonly parent: Agent + readonly signal: AbortSignal + readonly agentOptions?: AgentOptions + readonly outputSchema?: StructuredOutputSchema + readonly maxDepth?: number + readonly toolFilter?: ToolRestriction + readonly persona?: string +} +``` + +`signal` 是就绪前后唯一的取消通道。[subagent 组合控制 RFC](../rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 persona、实时全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 + +## 终态结果:`SubagentResult` + +一次运行的结果,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到,提供方在子 agent 失败或结束时未产出有效捕获时可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整:消费方将其映射为 `isError` 的工具结果,而非把不完整的输出当作成功上报。 + +```ts type-equiv +interface SubagentResult { + readonly output: ContentBlock[] + readonly structured?: unknown + readonly stopReason: SubagentStopReason +} +``` + +`SubagentStopReason` 是一个[可合并扩展的派生联合类型](core.md#the-map--derived-union-pattern):后端可以添加变体,因此消费方应对已知 case 分支处理,并将未知的终态原因视为失败: + +```ts type-equiv +interface SubagentStopReasonMap { + completed: 'completed' + aborted: 'aborted' + error: 'error' + 'max-tokens': 'max-tokens' + refusal: 'refusal' +} +``` + +## 活跃运行:`SubagentRun` + +`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose 该运行以达到静止态。子 agent 失败以非 completed 的 stop reason resolve;只有无法表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过其存在性公布运行时能力。 + +```ts type-equiv +interface SubagentRun { + readonly id: AgentId + readonly result: Promise + dispose(): Promise + sendMessage?(content: ContentBlock[]): void + resume?(content: ContentBlock[]): Promise +} +``` + +## 提供方 seam:`SubagentProvider` + +每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子行为(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型的措辞,而不暗示继承了工具、服务或权限。 + +```ts type-equiv +interface SubagentProvider { + readonly name: string + readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean + start(request: SubagentStartRequest): Promise +} +``` + +`start()` 仅在运行就绪时才 fulfill。服务观察其 result、发出 `subagent/start`,并返回同一个 run;rejection 意味着提供方已自行清理,且不发出生命周期事件对。进程内子 agent 可通过 `ctx.agents` 发现,远程子 agent 则不必如此。`subagent/end` 报告最终输出或基础设施故障。两个事件均为仅观察事件,包含监听器异常。 + +## 进程内后端:深度与种子 + +spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建过程,并通过 `AgentHandle` 进行 dispose。提供方被移除时会阻止新的 start,但不会撤销已接受的运行。每个子 agent 获得一个新的扁平作用域,而非继承父级的注册。深度和 fork 种子复用既有的 agent 与会话词汇: + +- **委派深度**是一个可合并扩展的 `AgentOptions.subagentDepth` 字段(顶层 agent 为 `0`,子 agent 为 parent + 1)。只有 `undefined` 表示顶层;每个已存储的 present 值必须是非负安全整数。该 seam 拥有此字段:循环既不设置也不读取它。嵌套 spawn 校验其父级的已存储深度,拒绝超出安全整数范围的派生子深度,并将已定义的绝对 `request.maxDepth` 上限应用于该子 agent。 +- **Fork 种子**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 resume 使用的是同一原语)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*:父级事件直到并包含其最后一个 `turn/end`。因此种子从 0 开始连续,[invariants](../../packages/support/invariants) 的回放能接受它(进行中的、未平衡的轮次被排除在外)。 diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml new file mode 100644 index 0000000000..b07308b84a --- /dev/null +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +system-prompt.md: 175f2af407e5c39a24f0f8f8e4e664063b897ead +system-prompt.zh.md: ea1f48c56f18c97203e1052d6d0eca72710e942d diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index 4b6f1e6625..175f2af407 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -1,5 +1,7 @@ # System Prompt Assembly +English | [中文](system-prompt.zh.md) + The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page pins the literal cross-package shapes that plugins implement or pass. Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts). diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md new file mode 100644 index 0000000000..ea1f48c56f --- /dev/null +++ b/docs/core-data-structures/system-prompt.zh.md @@ -0,0 +1,40 @@ +# 系统提示词组装 + +[English](system-prompt.md) | 中文 + +[system-prompt 包](../../packages/core/system-prompt)定义了提示词贡献方与单次组装调用之间交换的数据。包的 [README](../../packages/core/system-prompt/README.md) 文档记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 + +源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 + +## 组装上下文 + +`AssembleContext` 标识单次组装所解析的作用域层。它可通过合并扩展:`dsh-agent` 添加了可选的运行时 `agent` 字段,`assembleContextFor(agent)` 同时设置该字段与 `scope`。 + +```ts type-equiv +interface AssembleContext { + scope?: ScopeKey +} +``` + +## 工具提供方结果 + +`ToolProviderResult.schemas` 是当前组装中模型可见的工具集。`knownNames` 是提供方在限制前的完整名称集合,用于区分「配置名拼写错误」与「已知工具在此作用域下被有意隐藏」。 + +```ts type-equiv +interface ToolProviderResult { + readonly schemas: readonly ToolSchema[] + readonly knownNames?: readonly string[] +} +``` + +## 提示词段 + +`PromptSection` 是一个只读的同进程注册契约。其文本可以是静态的,也可以从当前组装上下文动态解析。 + +```ts type-equiv +interface PromptSection { + readonly name: string + readonly order: number + readonly text: string | ((context: AssembleContext) => string) +} +``` diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml new file mode 100644 index 0000000000..5b2b306ec9 --- /dev/null +++ b/docs/core-data-structures/tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +tools.md: f8be67054cd81027d4b751329948a784fa4f0ed9 +tools.zh.md: 080d0d99decb8630525e434a8079e29591e63ce9 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 132d22d521..f8be67054c 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -1,5 +1,7 @@ # Tools +English | [中文](tools.zh.md) + The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary. Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md new file mode 100644 index 0000000000..080d0d99de --- /dev/null +++ b/docs/core-data-structures/tools.zh.md @@ -0,0 +1,237 @@ +# 工具 + +[English](tools.md) | 中文 + +[dsh-tools](../../packages/core/tools) 的工具流水线。[core.md](core.md) 介绍了 `ToolDefinition` 作为唯一被提升到主干的流水线编写类型,以及 `ToolSchema` 作为面向模型的协议格式(wire format)。本页拥有完整的 `ToolDefinition`、构建它的类型化 schema DSL、带守卫的执行形状,以及 UI 展示词汇。 + +源码:[`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) + +## `ToolDefinition`:一个已注册的工具 + +一个 `ToolSchema`(面向模型的字段)加上 `execute` 函数与可选的 UI 展示器。注册表持有这些定义;agent loop(智能体循环)通过它们分发调用。注册表的 `schemas()` 通过显式白名单构建面向模型的 `ToolSchema[]`:`execute`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。 + +```ts type-equiv +interface ToolDefinition extends ToolSchema { + execute(args: unknown, exec: ToolExecution): Promise + /** + * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. + * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it + * is NEVER sent to the model — `schemas()` whitelists only name/description/ + * parameters. Declaring it asserts this tool forwards `exec.signal` to a + * cooperative implementation that can reach quiescence when the signal aborts. + */ + timeoutMs?: number + /** + * Optional: how to present the PENDING state of one call in a UI, derived from + * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows + * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent), + * or `undefined` (or omit the method) to fall back to a generic presentation + * (title = tool name, raw args as input). Pure and side-effect-free: a UI may + * call it during live streaming AND a session-log replay, so it must depend + * only on `args`. + */ + presentCall?(args: unknown): ToolCallView | undefined + /** + * Optional: how to present the COMPLETED state, given the same `args` and the + * `result` (`execute`'s content + whether it errored). Returns a + * {@link ToolResultView}, or `undefined` (or omit the method) to keep the + * pending title and render the raw result content. Pure and side-effect-free + * for the same replay reason. + */ + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined +} +``` + +`execute` 接收 `args: unknown`:原始的 `ToolDefinition` 自行校验输入。第一方工具不需要手写校验;它们使用 `defineTool`,由后者代为校验和收窄类型。 + +## 类型化 schema DSL + +插件作者为每个属性编写带有布尔值 `required: true` 的规格,类型层面的辅助工具将规格映射为 `execute` 的参数类型——零类型断言。该 DSL 是为 `ToolDefinition` *提供类型*的机制;它有意作为子页面细节,不属于核心。 + +源码:[`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) + +```ts type-equiv +interface SchemaProp { + type: SchemaType + /** Per-property required flag (NOT the JSON Schema top-level required array). */ + required?: true + /** Human-readable description, surfaced in the JSON Schema as well. */ + description?: string + /** Enum of allowed values (strings only). */ + enum?: string[] + /** Default value. */ + default?: unknown + /** Nested properties for type: 'object'. */ + properties?: SchemaSpec + /** Items schema for type: 'array'. */ + items?: SchemaProp +} +``` + +```ts type-equiv +type SchemaSpec = Record +``` + +`SchemaType` 是原始联合类型 `'string' | 'number' | 'boolean' | 'object' | 'array'`。`InferArgs` 将一个 `SchemaSpec` 映射为 TS 参数类型:`required: true` 的属性成为必选键,其余为真正的可选: + +```ts type-equiv +type InferArgs = Simplify< + & { [K in RequiredKeys]: InferPropValue } + & { [K in Exclude>]?: InferPropValue } +> +``` + +`defineTool({ name, description, parameters, execute, … })` 将各部分串联:`parameters` 是一个 `SchemaSpec`,`execute(args, exec)` 得到 `args: InferArgs`,辅助函数将规格转换为 JSON Schema(`schemaSpecToJsonSchema`)用于协议传输,并在类型化函数体运行前校验模型生成的参数(`validateArgs`)。不匹配时抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`),注册表将其转为 `isError` 结果以便模型自我修正。为什么用自定义 DSL 而非 schemastery:工具参数需要的是 JSON Schema(LLM(大语言模型)协议格式),不是校验/转换——轻量 DSL 以最小表面积提供最佳编写体验。 + +注册是受信的同进程契约。注册表以 readonly 方式借用类型化定义作为输入,仅校验语义要求(如 `timeoutMs` 必须为正有限值);`schemas()` 在模型边界处具象化显式的面向模型投影,使执行与展示共享同一份已解析定义,而不会将回调泄漏到协议上。 + +## `ToolRestriction`:单个作用域的实时全局过滤器 + +`ToolRestriction` 仅作用于实时的部署全局工具层。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域本地工具。仅 deny 的过滤器允许后续未列出的全局工具通过,而 allow 列表则排除它们。 + +```ts type-equiv +interface ToolRestriction { + readonly allow?: readonly string[] + readonly deny?: readonly string[] +} +``` + +## 执行:可扩展的 waterfall(瀑布式事件)加单调策略 + +`ctx.tools.execute()` 接受调用方拥有的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性具象化为流水线拥有的 `ToolExecution`,然后将该调用依次通过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调守卫 → `tools/execute`(around-dispatch 包装层)→ `tools/post-execute`(检查/替换结果)→ `tools/result`(不可变的权威结果)。最终结果是一个 `ToolExecutionResult`。 + +```ts type-equiv +type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } +``` + +```ts type-equiv +interface ToolExecutionInput { + readonly callId: CallId + readonly name: string + /** Parsed JSON arguments (unknown — tools validate their own input). */ + readonly arguments: unknown + /** The agent on whose behalf the call runs (set by the agent loop). */ + readonly agent?: Agent + /** + * Opaque token of the enclosing transport execution, when one exists. Code + * Mode sets this on SDK sub-dispatches so commit-style observers can wait for + * the outer `run_code` outcome without receiving its live mutable execution. + */ + readonly parent?: ToolExecutionToken + signal?: AbortSignal +} +``` + +```ts type-equiv +interface ToolExecution extends ToolExecutionInput { + /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ + readonly token: ToolExecutionToken +} +``` + +`ToolExecutionToken` 是一个不透明的运行时 `Symbol`,仅用于身份比较。在策略执行之前,`execute()` 具象化并冻结参数、拒绝非 JSON 输入、分配 token。身份字段和可选的 parent token 保持 readonly;只有 `signal` 可在 dispatch 前后变化。最终观察者接收到的是冻结的执行身份。 + +`ToolGuard` 是感知作用域的最终 pre-dispatch 策略。其形状有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 + +```ts type-equiv +type ToolGuard = (execution: Readonly) => string | undefined +``` + +```ts type-equiv +interface ToolExecutionResult { + content: ContentBlock[] + isError: boolean + /** + * Set when the call failed with a {@link HarnessError}: machine-routable + * `{ name, code }` for retry/sandbox plugins and replay. The model-facing + * text in `content` is always present; this is extra structure for code. + */ + error?: ToolErrorInfo + /** + * Extra model-facing context a `tools/post-execute` listener attached for the + * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part + * of this call's `content` — `content`/`feedback` shape the tool RESULT, but + * `additionalContext` is a SEPARATE `context/message`. A step can carry + * multiple tool calls, so the loop BUFFERS every call's `additionalContext` + * and appends them only AFTER all `tool/result`s for the step, keeping + * tool-call/result adjacency intact. Carried on the result purely to ferry it + * from `execute()` up to the loop's per-step buffer. + */ + additionalContext?: HookContext + /** + * The tool-private presentation payload from a successful `execute` (the object + * return form). Threaded onto the `tool/result` session event and back into + * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the + * tool attached none or the call failed. + */ + meta?: unknown +} +``` + +结果仅承载结果本身。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果通过每个钩子,也保留在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。 + +注册表在 `tools/result` 之前立即具象化并冻结最终接受的结果。其 content、结构化错误、附加上下文和展示元数据必须通过 JSON 无损往返;无效结果会被转为 JSON 安全的 `isError` 结果,确保被观察到的实时结果对后续持久化的 `tool/result` 追加是安全的。 + +每个拦截 waterfall 返回一个类型化的 **Decision**(与 `agent/*` seam 共享的惯用模式)。`tools/pre-execute` 监听器接收 `(exec, next)` 并返回 `PreToolDecision`;`tools/execute` 包装层返回 `ToolExecutionResult`;`tools/post-execute` 监听器接收 `(exec, result, next)` 并返回 `PostToolDecision`: + +```ts type-equiv +type PreToolDecision = + | { kind: 'allow' } + | { kind: 'deny'; reason: string } + | { kind: 'ask'; reason?: string } +``` + +```ts type-equiv +type PostToolDecision = + | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } +``` + +调用 `next()` 走默认路径,或返回 decision 以短路。Pre-policy 可以 deny 或 ask;只有 `allowed-once` 才继续执行,而 non-grant、缺少审批通道或服务、或无 agent 的请求都会变为 denial。守卫仍可施加最终 denial。参数不可被改写,因为历史记录、审计、UI 和执行必须一致。 + +Post-policy 可以替换 content;block 会变为包含其纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法转换它们,观察者的失败被隔离。未知工具和抛出异常的工具都变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 + +## 结构化输出 schema 子集 + +调用方用来向 subagent 要求机器可读结果的词汇(`SubagentStartRequest.outputSchema`,见 [subagent.md](subagent.md#the-start-request)),或工作流 `agent()` 调用使用的词汇。它有意**不是**完整的 JSON Schema:schema 原样传递给模型作为强制工具的 `parameters`,产出的值由客户端的 `validateStructuredValue` 校验——因此每个被接受的关键字都必须是校验器实际执行的,`assertSupportedOutputSchema` 会大声拒绝其他任何内容(`OutputSchemaError`,列出所有违规)。两个遍历器都只处理自有可枚举属性(JSON 不携带其他东西),并拒绝会有损序列化的非普通对象(`Date`、`Map`)。 + +```ts type-equiv +type StructuredScalar = string | number | boolean | null +``` + +```ts type-equiv +type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' +``` + +```ts type-equiv +interface StructuredSchemaNode { + type: StructuredSchemaType + properties?: Record + required?: string[] + additionalProperties?: boolean + items?: StructuredSchemaNode + enum?: StructuredScalar[] + const?: StructuredScalar + description?: string + title?: string + default?: unknown + examples?: unknown +} +``` + +schema 是一个以 object 为根的节点(`enum`/`const` 仅限标量;`description`/`title`/`default`/`examples` 是注解,允许但忽略,仍要求为 JSON 数据——它们随协议传输): + +```ts type-equiv +type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } +``` + +## 工具展示 UI 词汇 + +工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI 日志行),提供方无关,使工具无需依赖任何客户端协议即可描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: + +- `ToolCallView`(pending 状态):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示该调用读取/修改的文件,供编辑器跟随定位)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令 → 终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改 → 内联 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,`oldText: null` 表示新文件)。 +- `ToolResultView`(completed 状态):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,无能力的 UI 获得桥接层从 `output` 派生的围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更 → 要展示的变更,通常是从 before/after 内容计算出带上下文行的已应用 hunk,或在没有 before-image 时的整文件 diff——如文件创建。`tool_call_update` 的 content 会**替换**调用的 content,因此变更工具即使与调用时的片段重复也要返回此值,以防结果文本覆盖 diff)。 + +`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)为 generic 卡片选择图标。`FileLocation`(`{ path, line? }`)和 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计固定于[渲染意图联合类型 RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md);ACP(Agent Client Protocol)桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并将文件卡片的标题相对于会话 cwd 做相对化处理。 + +完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。bash 工具自身的 schema(`bash`/`bash_output`/`bash_kill`)及其驱动的执行器见 [bash.md](bash.md)。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml new file mode 100644 index 0000000000..f80530393b --- /dev/null +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +user-interaction.md: 47e0e26cd0a5201185dd252a882496456a9c3edd +user-interaction.zh.md: bddcc991fd48d475f06912b9134b331d623b80d2 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 4edc415039..47e0e26cd0 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -1,5 +1,7 @@ # User Interaction +English | [中文](user-interaction.zh.md) + The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md new file mode 100644 index 0000000000..bddcc991fd --- /dev/null +++ b/docs/core-data-structures/user-interaction.zh.md @@ -0,0 +1,99 @@ +# 用户交互 + +[English](user-interaction.md) | 中文 + +[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是工具或权限插件在需要人类回答后 agent 才能继续时所使用的提供方无关词汇。UI 表面提供活跃的 `UserInteractionProvider`:`dsh-stdio-demo` 在 readline 中渲染问题,`dsh-acp` 将其映射为 ACP 表单引出。 + +源码:[`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) + +## 问题选项 + +`AskUserQuestionOption` 是可选择项的形状。`label` 是面向用户的选项文字,同时也是模型侧选中后的值;`description` 是可选的 UI 辅助文字。 + +```ts type-equiv +interface AskUserQuestionOption { + /** User-facing label. */ + label: string + /** Optional extra context rendered by capable UIs. */ + description?: string +} +``` + +## 问题条目 + +`AskUserQuestionItem` 是请求中的一个问题。模型提供一个稳定的 `id`,回答时原样回传,使批量问题可路由。 + +```ts type-equiv +interface AskUserQuestionItem { + /** Stable model-provided question id, echoed in the answer. */ + id: string + /** The question to display. */ + question: string + /** Optional short heading/group label. */ + header?: string + /** Optional choices the UI can render as a menu. */ + options?: AskUserQuestionOption[] + /** Whether more than one option may be selected. Defaults to single-select. */ + multiSelect?: boolean +} +``` + +## 提问请求 + +`AskUserQuestionRequest` 是跨包请求。`questions` 是数组,这样 UI 可以在一次流程中展示相关问题,同时为每个回答保留稳定的 id。 + +```ts type-equiv +interface AskUserQuestionRequest { + /** Questions to display. */ + questions: AskUserQuestionItem[] + /** Calling agent, when the request came from an agent tool call. */ + agent?: Agent + /** Abort signal for the owning tool/step. */ + signal?: AbortSignal +} +``` + +## 回答 + +提供方为每个已回答的问题 id 返回一条回答。`selected` 包含选中的选项 label,`custom` 在用户输入了自由文本"其他"答案时携带该内容。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。 + +```ts type-equiv +interface AskUserQuestionAnswerItem { + /** The answered question id. */ + id: string + /** Selected option labels. Empty when the answer is purely custom text. */ + selected: string[] + /** Optional free-text "Other" answer. */ + custom?: string +} +``` + +```ts type-equiv +interface AskUserQuestionAnswer { + /** Structured answers keyed by question id. */ + answers: AskUserQuestionAnswerItem[] +} +``` + +## 提供方 + +同一上下文中只能有一个活跃的提供方。提供方注册与 effect 绑定,因此 HMR(热模块替换)或 dispose(资源释放)会移除活跃的 UI。 + +```ts type-equiv +interface UserInteractionProvider { + ask(request: AskUserQuestionRequest): Promise +} +``` + +## 错误 + +`UserInteractionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会为面向模型的工具失败保留 `{ name, code }`,例如 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`ASK_ABORTED` 或 ACP 侧的取消。 + +```ts type-equiv +class UserInteractionError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'UserInteractionError' + } +} +``` diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml new file mode 100644 index 0000000000..b5b4fd1f29 --- /dev/null +++ b/docs/core-data-structures/web.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +web.md: 74db18835df02ef233f78ad7fbfec5d9b26d58e6 +web.zh.md: da8dad9dc06309b6f3108148dc39bc2b66bb5d22 diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 9d79cd96c8..74db18835d 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -1,5 +1,7 @@ # Web Access +English | [中文](web.zh.md) + The web access seam — a [capability seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two capabilities** (search and fetch) on one `ctx.web` service, split across packages: interface ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), implementations ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-fetch-local](../../packages/web/web-fetch-local)), and consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-implementation swap does not change how the model asks for a URL. Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md new file mode 100644 index 0000000000..da8dad9dc0 --- /dev/null +++ b/docs/core-data-structures/web.zh.md @@ -0,0 +1,86 @@ +# Web 访问 + +[English](web.md) | 中文 + +Web 访问 seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md),在单一 `ctx.web` 服务上横跨**两种能力**(搜索与抓取),拆分到多个包(package)中:接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local)),以及消费方([dsh-tool-web](../../packages/web/tool-web),`web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此,而非 [core.md](core.md)。更换搜索提供方不会改变模型发起查询的方式,更换抓取实现也不会改变模型请求 URL 的方式。 + +源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) + +## 为何两种能力共用一个 seam + +搜索与抓取既不共享请求 schema,也不共享业务逻辑,但它们被有意设计为同一个 `ctx.web` 中间层:一个提供方选择策略的归属者、一套 abort/error 词汇、一个面向产品的「此 harness 如何访问 Web」配置界面。代价是服务上出现了并行的 `searchX`/`fetchX` 方法对;这种并行是有意为之,而非遗漏的提取。提供方注册的是**能力**(`WebSearchProvider` 或 `WebFetchProvider`),而非工具;面向模型的名称、schema、prompt 引导与展示全部集中在唯一的消费方 `dsh-tool-web` 中。 + +## 搜索请求与结果 + +面向模型的工具参数仅为一个 `query`;`maxResults` 是消费方持有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行:如果提供方返回的结果超量,seam 会截断 `sources[]` 并设置 `truncated`。 + +```ts type-equiv +interface WebSearchRequest { + readonly query: string + /** + * Upper bound on returned sources; the seam truncates to it. Omitted = no + * bound. `dsh-tool-web` always sets it. + */ + readonly maxResults?: number +} +``` + +```ts type-equiv +interface WebSearchResult { + readonly content?: string + readonly sources: readonly WebSearchSource[] + readonly truncated: boolean +} +``` + +`content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是可移植的引用界面。每条 source 必有 `url`;`title`/`snippet`/`publishedAt` 可选,因为并非所有提供方都返回它们:Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 + +```ts type-equiv +interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + readonly publishedAt?: string +} +``` + +## 抓取请求与结果 + +```ts type-equiv +interface WebFetchRequest { + readonly url: string +} +``` + +HTTP 状态码是被抓取资源状态的一部分,不自动视为失败:成功的网络抓取返回 `404`/`500` 时,结果仍是一个带状态码和有界解码 body 的 `WebFetchResult`。`url` 是经过允许的重定向后的最终 URL。`WebError` 保留给无法安全获取或表示资源的失败情形。 + +```ts type-equiv +interface WebFetchResult { + readonly url: string + readonly statusCode: number + readonly body: WebFetchBody + readonly truncated: boolean +} +``` + +`WebFetchBody` 是 `dsh-web` 持有的**封闭**可辨识联合类型(不是可合并扩展的 map):提供方解码 kind,`dsh-tool-web` 渲染它,因此新增一个 kind 是跨已知包的协调变更,而非插件扩展。消费方对 `kind` 做 `switch` 并以 `default: assertNever(...)` 结尾,因此新增 kind 会在每个消费方处破坏编译直到被处理。即使当前各分支字段相同,每个分支仍保持独立的对象字面量,为将来的分支特有字段留出空间(例如未来 `pdf` body 的 `pageCount`)。 + +```ts type-equiv +type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } +``` + +## 提供方可用性 + +提供方的 `available(): boolean` 是一个廉价的**本地**检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择的输入,而非健康检查系统:`search()`/`fetch()` 读取它来选出可用的提供方,选择失败以结构化的 `WebError` 呈现给调用方路由,其 code 和 message 携带可分支的细节(缺失的 id 或歧义的候选集)。 + +选择从不依赖注册顺序、配置顺序或 HMR 顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或喂入同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;多个可用提供方且未配置 id 时为 `WEB_PROVIDER_AMBIGUOUS`,而非先注册先赢。 + +## 错误 + +`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致:`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的 code,消费方必须容忍未知 code。code 按归属者划分。seam 中性的 code 由 `WebService` 选择逻辑和共享契约抛出:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身失败通过 seam 暴露的兜底 code,包括网络/传输失败:DNS、连接被拒、TLS)。抓取传输层 code 由 `dsh-web-fetch-local` 实现持有,不同的抓取后端不必抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 + +## 服务 + +`WebService` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、限制重定向次数、字节数、字符数与时间、对每一跳同源重定向重新校验,并解码 body;展示由工具负责。私有网络阻断尚未实现,因此不要在能触及敏感内部目标的环境中启用 `web_fetch`。 diff --git a/docs/core-data-structures/workflow.i18n.yaml b/docs/core-data-structures/workflow.i18n.yaml new file mode 100644 index 0000000000..b7e831aac8 --- /dev/null +++ b/docs/core-data-structures/workflow.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +workflow.md: 1571723c172fe851e89550e4ed8588ddb14088a0 +workflow.zh.md: 9fa3b4eea4efdcdb8e594c3558e30846aea0af46 diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 4354105e70..1571723c17 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -1,5 +1,7 @@ # Workflow +English | [中文](workflow.zh.md) + The workflow seam — an agent running a model-written orchestration SCRIPT that fans out subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). Unlike the subagent registry it takes the bash shape: ONE engine implementation per context provides `ctx.workflows`; there is no named-provider registry (a second engine is a plugin swap, not a co-resident). Interface: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The implementation is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md). diff --git a/docs/core-data-structures/workflow.zh.md b/docs/core-data-structures/workflow.zh.md new file mode 100644 index 0000000000..9fa3b4eea4 --- /dev/null +++ b/docs/core-data-structures/workflow.zh.md @@ -0,0 +1,71 @@ +# 工作流 + +[English](workflow.md) | 中文 + +工作流 seam:由 agent(智能体)运行一段模型编写的编排脚本(SCRIPT),向外扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 + +接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现为 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(基于 `node:worker_threads` 的引擎:每次运行一个 worker,脚本的 vm 上下文在其中执行);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见[动态工作流 RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md)。 + +源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) + +## 启动请求 + +调用方启动一次运行时发出的请求。工具层根据模型的 `{ script, meta, args }` 调用加上发起调用的 agent 构建此请求;`meta` 和 `args` 是纯 JSON 数据(引擎在任何代码运行之前对 `meta` 做形状校验,不通过则大声拒绝——永远不会为了获取 meta 而执行脚本文本)。`parent` 是必需的:脚本 spawn 的每个子 agent 都归属于它(cwd、血统和深度通过 [subagent seam](subagent.md) 流转)。 + +```ts type-equiv +interface WorkflowStartRequest { + script: string + meta: WorkflowMeta + args?: unknown + parent: Agent + signal?: AbortSignal +} +``` + +## 工作流的身份标识:`WorkflowMeta` + +作为数据附在启动请求上的身份块(工具的 `meta` 参数;字段词汇与 Claude Code 动态工作流的 meta 块一致)。`phases` 仅为进度词汇:`phase()` 调用与标题匹配供观察者使用;不暗示任何执行结构。 + +```ts type-equiv +interface WorkflowMeta { + name: string + description: string + whenToUse?: string + phases?: WorkflowPhase[] +} +``` + +## 终态结果:`WorkflowResult` + +一次运行的结果,由 `WorkflowRun.result` resolve。`value` 是脚本的物化返回值——纯宿主域 JSON 数据(脚本无返回值时为 `null`)——仅在 `completed` 时有意义。`stopReason` 是一个封闭联合类型(引擎拥有;消费方可穷举):`completed` | `cancelled` | `error`。非 `completed` 的原因在 `error` 中携带失败信息,消费方将其映射为 `isError` 工具结果,而非把部分输出当作成功上报。 + +```ts type-equiv +interface WorkflowResult { + value: unknown + stopReason: WorkflowStopReason + error?: string + agentsStarted: number +} +``` + +## 活跃运行:`WorkflowRun` + +脚本执行期间消费方持有的句柄。消费方 await `result`,可在运行中途 `cancel`,且必须在每条路径上调用 `dispose`。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后永远卡住。`dispose()` = cancel + 有界 settle + 子 agent 静默;它不会因脚本卡死而挂起。 + +```ts type-equiv +interface WorkflowRun { + readonly id: WorkflowRunId + readonly meta: WorkflowMeta + readonly result: Promise + cancel(reason?: string): void + dispose(): Promise +} +``` + +## 失败纪律:`WorkflowError.fatal` + +脚本内部的钩子误用——错误参数、未知或延迟的 `agent()` 选项、超出[结构化输出子集](../../packages/core/tools/README.md)的 schema、触发的上限、seam 启动失败、取消——会抛出 `fatal: true` 的 `WorkflowError`。`parallel()`/`pipeline()` 组合器对 fatal 错误执行重新抛出,而非将该项映射为 `null`:一个拼写错误的选项必须大声杀死脚本,绝不能消融为看似普通子 agent 失败的东西。逐项的 `null` 保留给子运行失败(非 `completed` 的 stop reason)和阶段内的普通脚本错误。 + +## 事件 + +`workflow/*` 事件(`workflow/start`、`workflow/phase`、`workflow/log`、`workflow/agent-start`、`workflow/agent-end`、`workflow/end`——见[事件目录](../cordis-catalog/events.md))是**仅供观察**的 emit,携带数据快照:每个 payload 以 `WorkflowRunInfo`(id + meta)开头,从不暴露活跃的 `WorkflowRun`,因此订阅者无法获得 `cancel`/`dispose`;`workflow/end` 刻意省略 result value(观察结果的监听器不得收到调用方 result 的可变别名)。每次 emit 对每个监听器隔离:抛异常的订阅者被记录但不传播,不会饿死其后注册的监听器;每个监听器收到自己的 payload 克隆,因此修改它既不会损坏引擎也不会影响其他监听器。这种隔离与 `subagent/start`/`subagent/end` 一致。 diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml new file mode 100644 index 0000000000..9e6c34072b --- /dev/null +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +0001-acp-default-export-drops-inject.md: 6a71d8d7ef72e3110a99774b180f3de7115ef622 +0001-acp-default-export-drops-inject.zh.md: 12bb3501a56c3cfef8f7a1b0d773be62db8e09ca diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index e024f4d698..6a71d8d7ef 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -1,5 +1,7 @@ # Post-mortem 0001: ACP server crashed on connect — `export default` dropped the plugin's `inject` +English | [中文](0001-acp-default-export-drops-inject.zh.md) + Status: resolved (fix in PR #41 `feat/acp-2-bridge`) ## Executive summary diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md new file mode 100644 index 0000000000..12bb3501a5 --- /dev/null +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -0,0 +1,113 @@ +# 事后分析 0001:ACP 服务器在连接时崩溃——`export default` 丢弃了插件的 `inject` + +[English](0001-acp-default-export-drops-inject.md) | 中文 + +Status: resolved (fix in PR #41 `feat/acp-2-bridge`) + +## 摘要 + +两个集成错误在单元测试全绿的情况下击溃了 ACP:一个 default export 导致 Loader 丢弃 `inject`,一个经过 traceable 代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复后新增了无需 API key 的真实 Loader 覆盖,以及关于插件导出和可选服务访问的包(package)规则。 + +## 概述 + +ACP 服务器(`examples/acp-agent`、`@deepseek-ai/dsh-acp`)在真实编辑器(Zed)连接的瞬间崩溃:第一个 `session/new` 请求返回 `Internal error: cannot get property "agents" without inject`,`session/load` 对 `sessionPersistence` 返回相同错误。尽管有 178 个绿色单元测试和 100% 行覆盖率,bridge 在生产环境中完全无法工作。两个独立的 bug 隐藏在同一个错误字符串背后,测试套件因同一个原因漏掉了二者:每个测试都通过一条不会触及插件实际加载方式或服务实际解析方式的路径来挂载插件。 + +## 影响 + +ACP 服务器无法创建或加载任何一个会话——这正是编辑器最先调用的两个 RPC。任何将 agent 接入 Zed 的人都会立即遇到硬性失败。无数据丢失(崩溃前没有持久化任何内容);代价完全是「功能不可用」加上两次定位原因的调试时间。 + +## 时间线 + +- Bridge(RFC 010)带着完整的单元测试套件(编解码、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试一起落地。全部绿色,100% 覆盖率。 +- 一次真实的 Zed 会话立即在 `session/new` 上失败,报错 `cannot get property "agents" without inject`。 +- 调查最初追踪的是 Cordis「traceable/shadow」理论(合理,且机制确实存在——见 Bug #2),随后在 vendor 的 `reflect.ts` 中对实际 fiber 遍历做了插桩,并运行了真实子进程。trace 显示 throw 发生在 `apply()` 第 179 行、**插件加载时**,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 +- 找到根因 #1:一行多余的 `export default apply`。移除后 `session/new` 修复。 +- 移除后暴露了 Bug #2:`session/load` 仍然在 `sessionPersistence` 上抛出——这是一个真正不同的机制(shadow 遍历),通过隔离修复并重新运行真实子进程得到确认。 + +## 根因 #1——`export default apply` 丢弃了插件的 `inject`(导致 `session/new` 崩溃) + +`packages/ui/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出——与仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`stdio-chat` 等)形状相同。但它*还*多了一行其他插件都没有的代码: + +```ts ignore-check +export const name = 'acp' +export const inject = ['agents', 'sessions', 'sessionPersistence'] +export function apply(ctx: Context, config: AcpConfig): void { /* … */ } +// … +export default apply // ← the bug +``` + +当插件从 `cordis.yml` 加载时,Cordis Loader 通过 `Loader.unwrapExports`(`vendor/loader/src/index.ts`)对导入的模块做规范化处理: + +```ts ignore-check +unwrapExports(exports: any) { + if (isNullable(exports)) return exports + exports = exports.default ?? exports // ← prefers `.default` + if (!exports.__esModule) return exports + return exports.default ?? exports +} +``` + +存在 default export 时,`exports.default ?? exports` 解析为**裸 `apply` 函数**。裸函数没有 `inject`、没有 `name`、没有 `Config` 属性——这些作为*兄弟*命名导出存在于模块命名空间上,而 unwrap 到 `.default` 把命名空间整个丢弃了。Loader 随后基于一个空的 `inject` 构建了插件的 fiber。 + +因此 `apply` 在一个**没有注入任何服务**的 fiber 中运行。第一行 `const agents = ctx.agents` 遍历 fiber 树(ROOT → Include → Loader → ROOT),在所有 fiber 的 store 中都找不到 `agents`,到达根 fiber(`runtime === null`)后抛出 `cannot get property "agents" without inject`。崩溃发生在*加载时*,而非后续的请求处理器中——请求只是恰好触发了加载。 + +**修复:**删除 `export default apply`。Loader 随后使用模块命名空间,正确识别 `inject`/`name`/`Config`,`apply` 在一个真正授予了声明服务的 fiber 中运行。 + +## 根因 #2——可选服务的属性读取在 traceable shadow 中触发 inject 守卫(导致 `session/load` 崩溃) + +修复 #1 后,`session/new` 正常工作,但 `session/load` 仍然抛出 `cannot get property "sessionPersistence" without inject`。这次*确实*是 Cordis 的 traceable/shadow 机制,值得精确理解。 + +`session/load` 调用 `agents.resume(...)`,后者委托给 `AgentLoop.resume()`,其中读取了 `this.ctx.sessionPersistence`。`AgentLoop` 的 `static inject` 故意**不**包含 `sessionPersistence`——注入它会导致非持久化的演示永远挂起,等待一个永远不会加载的后端。该服务由一个独立的兄弟插件/fiber 提供,按需读取。 + +Cordis 中的服务访问通过上下文代理(`vendor/cordis/src/reflect.ts`)进行。当通过从外部 fiber 获取的 *traceable 代理*调用服务方法时(此处:bridge fiber 调用 `ctx.agents.resume`,注册表返回 `this.factory`——即 `AgentLoop`——被重新包装为绑定到调用方的新 traceable 代理),`createShadowMethod`(`vendor/cordis/src/utils.ts`)将 `this` 重新绑定到一个 *shadow* 对象,其 `ctx` 携带 `[symbols.shadow]` 指向 `AgentLoop` 自身的构造上下文。在 `resume` 内部,`this.ctx.sessionPersistence` 的解析从 shadow 的 fiber 开始遍历: + +```ts ignore-check +// reflect.ts get handler +let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber // ← starts at AgentLoop's fiber +while (true) { + const impl = fiber.store?.[prop] + if (impl) return getTraceable(ctx, impl.value) + if (prop in fiber.inject) { /* inactive-context error */ } + if (!fiber.runtime) throw error // ← reached root, throw + if (fiber.parent[symbols.isolate][prop] !== key) throw error + fiber = fiber.parent.fiber // ← ancestor-only +} +``` + +遍历**只走祖先方向**。`sessionPersistence` 既不在 `AgentLoop` 的 fiber store 中(不在其 `static inject` 里),也不在通往根的任何祖先上(它在一个*兄弟*分支上),因此遍历到达根 fiber 后抛出。 + +为什么内存中的 `AgentLoop` resume 测试没有捕获到这个问题?因为它们从测试代码中直接调用 `ctx.agents.resume(...)`——*不在任何插件 fiber 内*。此时 `ctx.fiber.runtime` 为 `null`,代理处理器走了一条提前退出的路径: + +```ts ignore-check +if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct global-store lookup, no fiber walk +``` + +`ctx.reflect.get(name, false)` 是基于 isolate symbol 的全局服务 store 直接查找——完全忽略 fiber 拓扑,能找到服务。因此从顶层测试读取正常;从真实插件 fiber 内部、经由 shadow 到达时则抛出。bridge 恰好是后者。 + +**修复:**使用 `ctx.get('sessionPersistence')` 读取可选服务,该方法使用全局 isolate-keyed store,同时保留活跃状态检查。对于插件声明注入集中的服务,直接属性读取仍然适用。 + +## 为什么所有测试都漏掉了(真正的失败) + +两个 bug 共享同一个流程缺口:**没有任何测试通过插件的真实加载路径或真实调用拓扑来运行它。** + +- 内存 harness 通过手动构建插件对象来挂载 bridge:`ctx.plugin({ name, inject, apply })`。这手动提供了 `inject`,因此永远无法复现 Bug #1——`unwrapExports` 只被 *Loader* 调用,`ctx.plugin` 从不调用它。即使 `ctx.plugin(NamespaceImport)` 也无法捕获此问题。 +- 同一个 harness 把所有东西平铺挂载在一个根上下文上,因此从中触达的 `AgentLoop` resume 要么在顶层运行(`!runtime` 旁路),要么通过一个 origin 仍在根上解析的 shadow——掩盖了 Bug #2 的祖先遍历失败。 +- 唯一的无 key e2e 发送 `initialize` 并检查 stdout 纯净性。`initialize` 从不触达 factory,因此安然通过两个 bug。 +- 唯一驱动 `session/new`/`session/load` 的测试需要 key 才能运行,CI(无 key)跳过了它——而本地它之所以「通过」,只是因为一个陈旧的已构建 `lib/`(包含旧代码)恰好满足了模块解析。 + +100% 行覆盖率自始至终满足。覆盖率证明代码行*被执行过*;它不能说明功能是否*以交付的方式*工作。 + +## 新增的防护措施 + +- **移除 `export default apply`**(`packages/ui/acp/src/index.ts`)——Bug #1 的修复。 +- **`AgentLoop.resume` 使用 `this.ctx.get('sessionPersistence')`**(`packages/core/agent-loop/src/index.ts`)——Bug #2 的修复,附注释说明 shadow 遍历陷阱。 +- **无需 key 的 `session/new` e2e,通过真实 stdio 运行**(`examples/acp-agent/tests/acp.e2e.ts`):以子进程方式通过真实 Loader 启动示例,并断言 `session/new` 正常返回。无需 API key 即可在 Bug #1 上大声失败。已验证恢复 `export default apply` 时测试失败。 +- **e2e spawn 中设置 `TSX_TSCONFIG_PATH`**:子进程从临时 cwd 运行,tsx 无法通过向上搜索找到仓库根的 tsconfig `paths` 映射——因此 dsh-* 的导入静默回退到已构建的 `lib/`。将 tsx 指向仓库 tsconfig 使解析不依赖 cwd,确保测试运行的是*源码*而非可能陈旧的构建产物。 +- **[docs/testing.md](../testing.md) 规则**:「测试真实入口路径」,行覆盖率不等于行为覆盖率——将此教训编纂为所有未来插件的规则。 + +## 教训 + +- 命名空间插件与 default export 在 Cordis Loader 下互斥。选择命名空间形式(`name`/`inject`/`Config`/`apply`),不要添加 `export default`——`unwrapExports` 会丢弃命名空间。 +- 对于插件按需读取但**不**声明在 `static inject` 中的服务,使用 `ctx.get(name)`,绝不使用 `ctx.`。属性代理通过只走祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认严格——后端未激活时返回 `undefined`,而非在 teardown 过程中把半拆除的实例交出去)。 +- 手动构造插件的测试无法验证插件的加载方式。至少一个测试必须端到端地驱动真实的 Loader/export 路径。当核心操作不调用模型时,该测试无需 API key——因此它属于 CI,而非 key 门控之后。 +- 相信 trace,不要相信理论。优雅的 shadow 解释是真实的,但它是*第二个* bug;*第一个*是一行导出错误,在数小时合理但错误的推理之后,一条 fiber 遍历的 `console.error` 几分钟就找到了它。 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml new file mode 100644 index 0000000000..f59f7794de --- /dev/null +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +0002-js-expression-disabled-filesystem-tools.md: 43e57a6bd1b68f38c47eeda3c3abb8455024b350 +0002-js-expression-disabled-filesystem-tools.zh.md: e54431b7f4061bb4bdc22a37f0651697c6247dda diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md index 191c13459c..43e57a6bd1 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md @@ -1,5 +1,7 @@ # Post-mortem 0002: Filesystem snapshot tools were permanently disabled +English | [中文](0002-js-expression-disabled-filesystem-tools.zh.md) + Status: resolved ## Executive summary diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md new file mode 100644 index 0000000000..e54431b7f4 --- /dev/null +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -0,0 +1,47 @@ +# 事后分析 0002:文件系统快照工具被永久禁用 + +[English](0002-js-expression-disabled-filesystem-tools.md) | 中文 + +Status: resolved + +## 摘要 + +ACP 示例试图通过 `disabled: !!js ...` 有条件地启用文件系统插件,但 Cordis 仅在插件 `config` 内部求值 JavaScript 表达式。原始的表达式对象为 truthy,因此文件系统栈始终处于禁用状态。快照刷新随后将 `UNKNOWN_TOOL` 结果作为新的 golden 接受。修复方案使用显式的文件系统 overlay,并增加了静态配置守卫和快照结果守卫。 + +## 概述 + +默认的 ACP 组合有意仅包含 bash,因为其沙箱无法约束进程内的文件系统提供方。文件系统快照场景仍需要 `read`、`write` 和 `edit`,因此这些插件被放入默认的 `cordis.yml`,并附带一个 `disabled` 表达式,意图仅在全权限启动和快照模式下启用它们。 + +Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader 递归地对插件的 `config` 进行了插值,但直接消费了 `disabled` 等入口元数据。因此每个文件系统入口都看到一个 truthy 对象,在所有模式下均保持禁用。 + +## 影响 + +七个文件系统场景和一个混合工作区编辑场景调用了注册表中不存在的工具。它们的结构化会话日志携带 `ToolNotFoundError`(code 为 `UNKNOWN_TOOL`),stdout 则渲染了通用的失败工具卡片。快照套件通过了,因为两个表面都与刷新后的 fixture(测试前置数据)匹配;它证明的是回归的确定性回放,而非文件系统行为的正确性。 + +实际运行的受限默认组合并未获得意外的文件系统访问。一个朴素的插值修复反而会引入该风险:权限预设在运行时更新 bash 沙箱和审批状态,但无法挂载、卸载或约束文件系统栈。 + +## 时间线 + +- PR #261 整合了 ACP 组合并刷新了文件系统快照,同时引入了条件式文件系统入口。 +- 所有单元测试、覆盖率、快照、文档、构建和 hygiene 检查均通过。 +- 对刷新后的文件系统 golden 的评审发现了通用的失败卡片和结构化的 `UNKNOWN_TOOL` 结果。 +- 一次真实的 Loader 启动确认:每个 `disabled` 值仍然是表达式对象,每个文件系统 fiber 均未注册。 + +## 根因 + +实现方假设 `!!js` 适用于整个 Loader 入口。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不做插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 + +快照框架将任何确定性的 transcript(文本记录)视为有效行为。Header pin 验证了组合后的工具 schema,但文件系统场景共享了来自默认组合的 pin,因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前,就已重写了预期的 stdout 和会话日志。 + +## 新增的防护措施 + +- 文件系统场景启动 `fs.cordis.yml`:一个显式的固定全权限 overlay,配有对应的 replay 配置和独立的 request-header 类。 +- [`AGENTS.md`](../../AGENTS.md) 和 [Cordis 入门](../cordis-primer.md#loader-configuration) 明确说明 `!!js` 仅在插件 `config` 下有效,条件式组合应使用 overlay。 +- `verify-cordis-config` 解析仓库中的 Cordis YAML,拒绝 Loader 入口元数据(包括 include patch 和插入的入口)中出现表达式节点。 +- `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,阻止其成为被接受的 golden。 + +## 教训 + +- 语法上被接受的配置值不一定在该位置被求值;应记录并验证插值边界。 +- 快照刷新是 fixture 生产,不是正确性评审。像「已注册工具缺失」这样的语义不可能性需要独立于 golden 的断言。 +- 权限控制只应描述它实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml new file mode 100644 index 0000000000..f57d280d36 --- /dev/null +++ b/docs/postmortem/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 4dc59e4f5e70f51c4c0baa64fbe34b213f2a7c3d +README.zh.md: 7e2d05d429b2521e7e772956b7644740d4642fac diff --git a/docs/postmortem/README.md b/docs/postmortem/README.md index 743433a827..4dc59e4f5e 100644 --- a/docs/postmortem/README.md +++ b/docs/postmortem/README.md @@ -1,5 +1,7 @@ # Post-mortems +English | [中文](README.zh.md) + Incident write-ups: a bug reached a place it shouldn't have (a real user, a merged PR, a release), and the interesting part is *why our process let it through*, not just the one-line fix. A post-mortem is NOT an [RFC](../rfc/README.md) (which records a deliberate design decision and its rejected alternatives, or proposes future work). It is a backward-looking record of a failure: what broke, the mechanism, why every safety net missed it, and the concrete guardrails added so the same class of bug fails loudly next time. diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md new file mode 100644 index 0000000000..7e2d05d429 --- /dev/null +++ b/docs/postmortem/README.zh.md @@ -0,0 +1,16 @@ +# 事后分析 + +[English](README.md) | 中文 + +事故记录:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),有意义的部分是**为什么我们的流程放过了它**,而不仅仅是那行修复。 + +事后分析不是 [RFC](../rfc/README.md)(RFC 记录的是经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份面向过去的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体护栏使同类 bug 下次能快速失败。 + +满足以下条件时写一篇:bug **隐蔽**(机制不显而易见,一位细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性手误)、**重新发现的代价高**(它消耗了真实的调试时间,而且下次还会)。请链接该事后分析所推动建立的护栏(测试、AGENTS.md 规则、ADR)。 + +每篇事后分析以一段 **Executive summary** 开头:一段简短的文字,让忙碌的读者在三十秒内了解全貌——什么坏了、用通俗语言说的根因、为什么逃逸了、以及持久的教训——之后再展开详细的 Summary / Timeline / Root cause / Guardrails 各节。 + +| # | 标题 | +|---|---| +| [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | +| [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | diff --git a/docs/rfc/README.i18n.yaml b/docs/rfc/README.i18n.yaml new file mode 100644 index 0000000000..5bda02fc38 --- /dev/null +++ b/docs/rfc/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 9014579f3a98be907885332a0c815bca5c96855c +README.zh.md: b51343b35aa04830b694f560ca9ae490995dcd43 diff --git a/docs/rfc/README.md b/docs/rfc/README.md index a6fe4dbfa2..9014579f3a 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -1,5 +1,7 @@ # RFCs +English | [中文](README.zh.md) + One kind of design doc lives here. An **RFC** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. The full list is the generated [INDEX.md](INDEX.md); this file is the contract — where RFCs live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming diff --git a/docs/rfc/README.zh.md b/docs/rfc/README.zh.md new file mode 100644 index 0000000000..b51343b35a --- /dev/null +++ b/docs/rfc/README.zh.md @@ -0,0 +1,111 @@ +# RFC + +[English](README.md) | 中文 + +这里存放一类设计文档。**RFC** 记录塑造本代码库的决策或提案——代码和文档本身无法承载的*为什么*以及*放弃了什么*。完整列表见生成的 [INDEX.md](INDEX.md);本文是契约——RFC 放在哪里、何时该写,以及[文件内格式](#the-file-format)。 + +## 布局与命名 + +每篇 RFC 有两个轴,都编码在其**路径**中——`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`: + +- **生命周期**(顶层文件夹)是 RFC 的状态,RFC 随状态变更在文件夹间移动: + - **`proposed/`**——实现前评审的提案;尚未构建(或仅部分构建)。 + - **`implemented/`**——决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后来移动了文件、重命名了包(package)或更改了键/默认值时,RFC 在同一个变更中更新以匹配(仅限事实——路径、名称、结构——不涉及决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 + - **`rejected/`**——提案经考虑后被否决。保留以备查阅,避免同一问题被反复争论。 +- **分类**(嵌套文件夹)是决策的*类型*——见下方[分类](#classification)。 + +文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。RFC 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 + +## 分类 + +每篇 RFC 归属于 `scripts/rfc-index.ts` 中封闭集合里的一个路径编码分类;分类门禁拒绝其他文件夹。[INDEX.md](INDEX.md) 由路径、标题和文件名日期生成,其新鲜度受门禁保护。新增分类需要同时更新规范集合与本节。见[分类 RFC](implemented/process/2026-06-20-rfc-classification.md) 与[索引生成 RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md)。 + +| 分类 | 涵盖内容 | +|---|---| +| `feature` | 面向用户或模型的新能力。 | +| `bug-fix` | 修正缺陷或填补事后复盘暴露的空白。 | +| `simplification` | 在不增加能力的前提下移除代码、行为或接口面。 | +| `architecture` | 关于**交付源码**的结构性决策——包之间的关系、运行时词汇。 | +| `process` | 围绕代码的工具、政策或工作流——门禁、包管理器、vendor 化——而非运行时行为。 | +| `testing` | 测试基础设施与策略。 | + +`architecture` 与 `process` 的分界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。(`refactor` 被刻意省略——它与 `simplification` 重叠,后者的判别标准「可观测行为是否改变」已覆盖了它。) + +## 何时该写 + +当一个决策**持久**(它塑造代码库的范围超出单个函数或包)、**有争议**(存在一个合理工程师可能选择的真实替代方案)、且**令人意外**(未来读者否则会问「为什么要这样做」)时,请写一篇 RFC。对未来大量工作的提案从 `proposed/` 开始;已做出的决策从 `implemented/` 开始。选择与决策匹配的分类文件夹(见[分类](#classification))。 + +以下情况**不要**写 RFC:机械性或局部的选择(变量名、单文件重构);已由门禁或 AGENTS.md 中的约定强制并解释的事项;代码中标记为 `TODO(...)` 的暂定决策——将其记为 TODO,待尘埃落定后再提升为 RFC。RFC 永远不会被编辑成*另一个决策*:用新 RFC 取代旧的并互相链接。(编辑 `implemented/` RFC 以跟踪其已做出的决策现在*位于何处*——移动的文件、重命名的包——不是另一个决策,是必须做的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。) + +## 文件格式 + +每篇 RFC 遵循统一的文件内格式,由 `pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../scripts/verify-rfc-format.ts),doc-sync(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 RFC](implemented/process/2026-07-05-uniform-rfc-format.md)。 + +### 头部块 + +每篇 RFC 的前三行严格为: + +```markdown +# RFC: + +Status: <status> +``` + +后接一个空行。`Status:` 的值有三种形式,且必须与文件所在的生命周期文件夹一致——门禁会交叉检查: + +- `Status: proposed` +- `Status: implemented` +- `Status: rejected — <why, in one line>` + +状态行不带日期、不带括号补充说明:文件名承载首次提出日期,git 承载其余一切,「以修订形式接受」之类的说明属于正文内容(在陈述决策的地方说明修订)。否决原因是唯一带内容的状态行,因为读者查阅被否决 RFC 时要的就是结论。 + +### 正文骨架 + +每篇 RFC 的正文以 `## Problem` 开头——动机,写法应独立于解决方案。后续内容取决于生命周期;重复出现的章节使用以下规范名称且仅限这些名称,而真正特有的技术章节(包拓扑、协议格式(wire format)、schema)在必需章节之间自由编排。 + +#### `proposed/` + +```markdown +## Problem +## Proposal +…bespoke sections… +## Alternatives considered +## Acceptance criteria +## Risks +``` + +`## Proposal` 是拟议的变更,可以正当地使用将来时——计划、迁移步骤和未决问题在工作尚未构建时属于此处。`## Acceptance criteria` 说明什么可观测状态意味着完成。`## Risks` 涵盖可能出错的事项以及变更有意放弃的东西。 + +#### `implemented/` + +```markdown +## Problem +## Decision +…bespoke sections… +## Alternatives considered +## Consequences +``` + +`## Decision` 以现在时描述已交付的现实,整个文件按 [implemented/AGENTS.md](implemented/AGENTS.md) 的要求与之保持同步。`## Consequences` 记录权衡的代价**与**收益。提案阶段的标题在这里属于规格用语,门禁会拒绝:`## Proposal`、`## Plan`、`## Migration plan` 和 `## Acceptance criteria` 不得出现在 implemented RFC 中([slop 检查清单](../AGENTS.md)说明了原因)。`## Testing`、`## Deferred` 或 `## Related` 章节在陈述现在时事实时是允许的。 + +#### `rejected/` + +被否决的 RFC 是冻结的提案:保留其提案时的所有章节(包括 `## Acceptance criteria` 或 `## Plan`),结论写在 `Status:` 行。仅头部块、`## Problem` 开头、`## Proposal` 章节,以及下方的「曾考虑的替代方案」强制要求适用。 + +### 曾考虑的替代方案——强制要求 + +每篇 RFC 都必须有一个 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案一段(加粗引导),或对争议较大的方案使用 `### Why not <X>?` 子章节。记录决策却不记录它击败了什么,就是在邀请反复争论——正是 RFC 存在的目的所要防止的。 + +替代方案是记录下来的,而非凭空编造的。日期早于 2026-07-05 的 RFC,如果其替代方案无法从记录中重建,则在该章节位置放置以下精确注释,门禁仅对格式前文件接受此注释: + +```markdown +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +``` + +### 在生命周期间移动 + +将文件在生命周期文件夹间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折叠进 `## Consequences`(或一个现在时的 `## Testing`/`## Verification` 章节,用于说明现在什么在固定该行为),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 要求的改写,使之机械化。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。 + +### 中文对侧文件 + +`.zh.md` 对侧文件按 [i18n 契约](../i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# RFC: ` 和 `Status:` 行)保持英文原样不变。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index ee0a35b599..be766dbaad 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -10,11 +10,33 @@ "docs/cookbook/extension-cookbook.md", "docs/cookbook/responding-to-pr-review-on-a-stack.md", "docs/cordis-primer.md", + "docs/core-data-structures/approval.md", + "docs/core-data-structures/bash.md", + "docs/core-data-structures/code-runtime.md", + "docs/core-data-structures/compaction.md", + "docs/core-data-structures/filesystem.md", + "docs/core-data-structures/llm-streaming.md", + "docs/core-data-structures/persistence.md", + "docs/core-data-structures/sandbox.md", + "docs/core-data-structures/scope.md", + "docs/core-data-structures/session-query.md", + "docs/core-data-structures/session.md", + "docs/core-data-structures/skills.md", + "docs/core-data-structures/subagent.md", + "docs/core-data-structures/system-prompt.md", + "docs/core-data-structures/tools.md", + "docs/core-data-structures/user-interaction.md", + "docs/core-data-structures/web.md", + "docs/core-data-structures/workflow.md", "docs/defensive-patterns.md", "docs/development.md", "docs/glossary.md", "docs/i18n/README.md", "docs/i18n/translation-rules.md", + "docs/postmortem/0001-acp-default-export-drops-inject.md", + "docs/postmortem/0002-js-expression-disabled-filesystem-tools.md", + "docs/postmortem/README.md", + "docs/rfc/README.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", "docs/testing.md", @@ -35,6 +57,7 @@ "docs/i18n/translation-prompt.md", "docs/module-graph.md", "docs/persistence-catalog.md", + "docs/rfc/INDEX.md", "docs/tool-catalog.md", "docs/tool-execution-pipeline.md", "python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/" From 2565133af3840bfe6dae4b7284eb796718d02171 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:25:06 -0700 Subject: [PATCH 003/113] =?UTF-8?q?docs(i18n):=20RFC=20tree=20batch=20?= =?UTF-8?q?=E2=80=94=20146=20bilingual=20pairs=20via=20the=20committed=20p?= =?UTF-8?q?ipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit implemented(除 4 篇超长文档随后补)、proposed、rejected 全树配对; 同一流水线 + 二遍校验(paraphrase-back + 仓库上下文一致性)产出。 docs/rfc/implemented/AGENTS.md 与其 CLAUDE.md 符号链接列入排除 (agent 指令文件,与根 AGENTS.md 同策略)。 --- ...6-06-11-content-block-vocabulary.i18n.yaml | 6 + .../2026-06-11-content-block-vocabulary.md | 2 + .../2026-06-11-content-block-vocabulary.zh.md | 28 +++ .../2026-06-11-custom-schema-dsl.i18n.yaml | 6 + .../2026-06-11-custom-schema-dsl.md | 2 + .../2026-06-11-custom-schema-dsl.zh.md | 23 ++ ...ev-invariants-over-deep-readonly.i18n.yaml | 6 + ...06-11-dev-invariants-over-deep-readonly.md | 2 + ...11-dev-invariants-over-deep-readonly.zh.md | 60 +++++ ...026-06-11-event-sourced-sessions.i18n.yaml | 6 + .../2026-06-11-event-sourced-sessions.md | 2 + .../2026-06-11-event-sourced-sessions.zh.md | 28 +++ ...06-11-microkernel-event-taxonomy.i18n.yaml | 6 + .../2026-06-11-microkernel-event-taxonomy.md | 2 + ...026-06-11-microkernel-event-taxonomy.zh.md | 31 +++ ...026-06-11-runtime-arg-validation.i18n.yaml | 6 + .../2026-06-11-runtime-arg-validation.md | 2 + .../2026-06-11-runtime-arg-validation.zh.md | 24 ++ ...-06-11-structured-error-taxonomy.i18n.yaml | 6 + .../2026-06-11-structured-error-taxonomy.md | 2 + ...2026-06-11-structured-error-taxonomy.zh.md | 26 +++ ...-tool-schemas-in-prompt-assembly.i18n.yaml | 6 + ...6-06-11-tool-schemas-in-prompt-assembly.md | 2 + ...6-11-tool-schemas-in-prompt-assembly.zh.md | 23 ++ .../2026-06-13-capability-seams.i18n.yaml | 6 + .../2026-06-13-capability-seams.md | 2 + .../2026-06-13-capability-seams.zh.md | 32 +++ .../2026-06-13-twin-llm-adapters.i18n.yaml | 6 + .../2026-06-13-twin-llm-adapters.md | 2 + .../2026-06-13-twin-llm-adapters.zh.md | 27 +++ .../2026-06-14-session-persistence.i18n.yaml | 6 + .../2026-06-14-session-persistence.md | 2 + .../2026-06-14-session-persistence.zh.md | 36 +++ ...6-06-15-turn-enclosure-invariant.i18n.yaml | 6 + .../2026-06-15-turn-enclosure-invariant.md | 2 + .../2026-06-15-turn-enclosure-invariant.zh.md | 42 ++++ ...06-17-filesystem-capability-seam.i18n.yaml | 6 + .../2026-06-17-filesystem-capability-seam.md | 2 + ...026-06-17-filesystem-capability-seam.zh.md | 160 +++++++++++++ ...nt-lifecycle-and-ownership-seams.i18n.yaml | 6 + ...-18-agent-lifecycle-and-ownership-seams.md | 2 + ...-agent-lifecycle-and-ownership-seams.zh.md | 48 ++++ .../2026-06-18-session-surface.i18n.yaml | 6 + .../2026-06-18-session-surface.md | 2 + .../2026-06-18-session-surface.zh.md | 71 ++++++ ...ed-persistence-write-coordinator.i18n.yaml | 6 + ...18-shared-persistence-write-coordinator.md | 2 + ...shared-persistence-write-coordinator.zh.md | 44 ++++ .../2026-06-20-branded-ids.i18n.yaml | 6 + .../architecture/2026-06-20-branded-ids.md | 2 + .../architecture/2026-06-20-branded-ids.zh.md | 69 ++++++ ...-20-extract-example-app-packages.i18n.yaml | 6 + ...2026-06-20-extract-example-app-packages.md | 2 + ...6-06-20-extract-example-app-packages.zh.md | 57 +++++ .../2026-06-20-package-hierarchy.i18n.yaml | 6 + .../2026-06-20-package-hierarchy.md | 2 + .../2026-06-20-package-hierarchy.zh.md | 75 ++++++ ...andatory-app-attribution-headers.i18n.yaml | 6 + ...06-21-mandatory-app-attribution-headers.md | 2 + ...21-mandatory-app-attribution-headers.zh.md | 84 +++++++ ...06-26-file-context-as-event-gate.i18n.yaml | 6 + .../2026-06-26-file-context-as-event-gate.md | 2 + ...026-06-26-file-context-as-event-gate.zh.md | 171 ++++++++++++++ ...stdin-env-trusted-plugin-surface.i18n.yaml | 6 + ...0-bash-stdin-env-trusted-plugin-surface.md | 2 + ...ash-stdin-env-trusted-plugin-surface.zh.md | 33 +++ ...026-06-30-event-domain-semantics.i18n.yaml | 6 + .../2026-06-30-event-domain-semantics.md | 2 + .../2026-06-30-event-domain-semantics.zh.md | 39 ++++ .../2026-07-02-fs-per-session-cwd.i18n.yaml | 6 + .../2026-07-02-fs-per-session-cwd.md | 2 + .../2026-07-02-fs-per-session-cwd.zh.md | 34 +++ ...2-result-time-applied-hunk-diffs.i18n.yaml | 6 + ...26-07-02-result-time-applied-hunk-diffs.md | 2 + ...07-02-result-time-applied-hunk-diffs.zh.md | 61 +++++ ...6-07-02-tool-render-intent-union.i18n.yaml | 6 + .../2026-07-02-tool-render-intent-union.md | 2 + .../2026-07-02-tool-render-intent-union.zh.md | 82 +++++++ ...ilesystem-directory-listing-seam.i18n.yaml | 6 + ...07-03-filesystem-directory-listing-seam.md | 2 + ...03-filesystem-directory-listing-seam.zh.md | 53 +++++ ...bles-and-tool-guidance-ownership.i18n.yaml | 6 + ...t-variables-and-tool-guidance-ownership.md | 2 + ...ariables-and-tool-guidance-ownership.zh.md | 72 ++++++ ...6-07-05-reconstructable-requests.i18n.yaml | 6 + .../2026-07-05-reconstructable-requests.md | 2 + .../2026-07-05-reconstructable-requests.zh.md | 55 +++++ ...bagent-provider-lifecycle-events.i18n.yaml | 6 + ...7-05-subagent-provider-lifecycle-events.md | 2 + ...5-subagent-provider-lifecycle-events.zh.md | 36 +++ ...6-07-06-timeout-deadline-library.i18n.yaml | 6 + .../2026-07-06-timeout-deadline-library.md | 2 + .../2026-07-06-timeout-deadline-library.zh.md | 98 ++++++++ ...6-07-07-tool-call-timeout-policy.i18n.yaml | 6 + .../2026-07-07-tool-call-timeout-policy.md | 2 + .../2026-07-07-tool-call-timeout-policy.zh.md | 111 +++++++++ .../2026-07-08-agent-scope-contexts.i18n.yaml | 6 + .../2026-07-08-agent-scope-contexts.md | 2 + .../2026-07-08-agent-scope-contexts.zh.md | 172 ++++++++++++++ ...-06-14-acp-agent-client-protocol.i18n.yaml | 6 + .../2026-06-14-acp-agent-client-protocol.md | 2 + ...2026-06-14-acp-agent-client-protocol.zh.md | 59 +++++ .../2026-06-14-acp-multi-session.i18n.yaml | 6 + .../feature/2026-06-14-acp-multi-session.md | 2 + .../2026-06-14-acp-multi-session.zh.md | 39 ++++ .../feature/2026-06-15-code-mode.i18n.yaml | 6 + .../feature/2026-06-15-code-mode.md | 2 + .../feature/2026-06-15-code-mode.zh.md | 132 +++++++++++ ...26-06-17-filesystem-tool-schemas.i18n.yaml | 6 + .../2026-06-17-filesystem-tool-schemas.md | 2 + .../2026-06-17-filesystem-tool-schemas.zh.md | 112 +++++++++ ...-acp-terminal-and-tool-rendering.i18n.yaml | 6 + ...6-06-18-acp-terminal-and-tool-rendering.md | 2 + ...6-18-acp-terminal-and-tool-rendering.zh.md | 48 ++++ ...06-18-compaction-capability-seam.i18n.yaml | 6 + .../2026-06-18-compaction-capability-seam.md | 2 + ...026-06-18-compaction-capability-seam.zh.md | 127 +++++++++++ ...6-06-21-subagent-capability-seam.i18n.yaml | 6 + .../2026-06-21-subagent-capability-seam.md | 2 + .../2026-06-21-subagent-capability-seam.zh.md | 74 ++++++ .../2026-06-22-acp-subagent-backend.i18n.yaml | 6 + .../2026-06-22-acp-subagent-backend.md | 2 + .../2026-06-22-acp-subagent-backend.zh.md | 57 +++++ .../2026-06-25-ask-user-question.i18n.yaml | 6 + .../feature/2026-06-25-ask-user-question.md | 2 + .../2026-06-25-ask-user-question.zh.md | 51 +++++ .../2026-06-29-todo-write-tool.i18n.yaml | 6 + .../feature/2026-06-29-todo-write-tool.md | 2 + .../feature/2026-06-29-todo-write-tool.zh.md | 64 ++++++ .../feature/2026-06-30-hook-bridges.i18n.yaml | 6 + .../feature/2026-06-30-hook-bridges.md | 2 + .../feature/2026-06-30-hook-bridges.zh.md | 70 ++++++ .../2026-06-30-hook-protocol-lib.i18n.yaml | 6 + .../feature/2026-06-30-hook-protocol-lib.md | 2 + .../2026-06-30-hook-protocol-lib.zh.md | 32 +++ .../2026-06-30-interception-seams.i18n.yaml | 6 + .../feature/2026-06-30-interception-seams.md | 2 + .../2026-06-30-interception-seams.zh.md | 60 +++++ ...026-06-30-session-store-fork-api.i18n.yaml | 6 + .../2026-06-30-session-store-fork-api.md | 2 + .../2026-06-30-session-store-fork-api.zh.md | 43 ++++ ...26-06-30-subagent-observe-enrich.i18n.yaml | 6 + .../2026-06-30-subagent-observe-enrich.md | 2 + .../2026-06-30-subagent-observe-enrich.zh.md | 31 +++ .../2026-07-05-dynamic-workflows.i18n.yaml | 6 + .../feature/2026-07-05-dynamic-workflows.md | 2 + .../2026-07-05-dynamic-workflows.zh.md | 80 +++++++ .../feature/2026-07-05-skill-system.i18n.yaml | 6 + .../feature/2026-07-05-skill-system.md | 2 + .../feature/2026-07-05-skill-system.zh.md | 55 +++++ .../2026-07-06-approval-seam.i18n.yaml | 6 + .../feature/2026-07-06-approval-seam.md | 2 + .../feature/2026-07-06-approval-seam.zh.md | 138 +++++++++++ .../2026-07-06-explicit-tool-order.i18n.yaml | 6 + .../feature/2026-07-06-explicit-tool-order.md | 2 + .../2026-07-06-explicit-tool-order.zh.md | 50 ++++ .../2026-07-07-mcp-client-plugin.i18n.yaml | 6 + .../feature/2026-07-07-mcp-client-plugin.md | 2 + .../2026-07-07-mcp-client-plugin.zh.md | 214 ++++++++++++++++++ .../2026-07-07-session-prefix.i18n.yaml | 6 + .../feature/2026-07-07-session-prefix.md | 2 + .../feature/2026-07-07-session-prefix.zh.md | 44 ++++ .../2026-07-08-repeat-tool-guard.i18n.yaml | 6 + .../feature/2026-07-08-repeat-tool-guard.md | 2 + .../2026-07-08-repeat-tool-guard.zh.md | 76 +++++++ ...-self-referential-cordis-toolset.i18n.yaml | 6 + ...6-07-08-self-referential-cordis-toolset.md | 2 + ...7-08-self-referential-cordis-toolset.zh.md | 82 +++++++ ...2026-07-10-session-query-service.i18n.yaml | 6 + .../2026-07-10-session-query-service.md | 2 + .../2026-07-10-session-query-service.zh.md | 43 ++++ ...nt-persona-tool-filter-and-depth.i18n.yaml | 6 + ...-subagent-persona-tool-filter-and-depth.md | 2 + ...bagent-persona-tool-filter-and-depth.zh.md | 94 ++++++++ .../2026-06-11-doc-sync-enforcement.i18n.yaml | 6 + .../2026-06-11-doc-sync-enforcement.md | 2 + .../2026-06-11-doc-sync-enforcement.zh.md | 32 +++ .../2026-06-11-quality-gates.i18n.yaml | 6 + .../process/2026-06-11-quality-gates.md | 2 + .../process/2026-06-11-quality-gates.zh.md | 28 +++ .../2026-06-11-tsdown-over-dumble.i18n.yaml | 6 + .../process/2026-06-11-tsdown-over-dumble.md | 2 + .../2026-06-11-tsdown-over-dumble.zh.md | 30 +++ ...26-06-11-vendor-cordis-as-source.i18n.yaml | 6 + .../2026-06-11-vendor-cordis-as-source.md | 2 + .../2026-06-11-vendor-cordis-as-source.zh.md | 27 +++ .../2026-06-16-pnpm-over-yarn.i18n.yaml | 6 + .../process/2026-06-16-pnpm-over-yarn.md | 2 + .../process/2026-06-16-pnpm-over-yarn.zh.md | 43 ++++ .../2026-06-17-ts-build-config.i18n.yaml | 6 + .../process/2026-06-17-ts-build-config.md | 2 + .../process/2026-06-17-ts-build-config.zh.md | 78 +++++++ ...6-06-18-markdown-cross-link-lint.i18n.yaml | 6 + .../2026-06-18-markdown-cross-link-lint.md | 2 + .../2026-06-18-markdown-cross-link-lint.zh.md | 33 +++ ...-20-core-data-structures-catalog.i18n.yaml | 6 + ...2026-06-20-core-data-structures-catalog.md | 2 + ...6-06-20-core-data-structures-catalog.zh.md | 60 +++++ ...6-06-20-generated-cordis-catalog.i18n.yaml | 6 + .../2026-06-20-generated-cordis-catalog.md | 2 + .../2026-06-20-generated-cordis-catalog.zh.md | 41 ++++ .../2026-06-20-rfc-classification.i18n.yaml | 6 + .../process/2026-06-20-rfc-classification.md | 2 + .../2026-06-20-rfc-classification.zh.md | 48 ++++ .../2026-07-02-tool-schema-catalog.i18n.yaml | 6 + .../process/2026-07-02-tool-schema-catalog.md | 2 + .../2026-07-02-tool-schema-catalog.zh.md | 55 +++++ ...-07-03-documentation-graph-atlas.i18n.yaml | 6 + .../2026-07-03-documentation-graph-atlas.md | 2 + ...2026-07-03-documentation-graph-atlas.zh.md | 69 ++++++ ...4-cordis-jsdoc-completeness-gate.i18n.yaml | 6 + ...26-07-04-cordis-jsdoc-completeness-gate.md | 2 + ...07-04-cordis-jsdoc-completeness-gate.zh.md | 41 ++++ ...2026-07-04-doc-tiers-and-budgets.i18n.yaml | 6 + .../2026-07-04-doc-tiers-and-budgets.md | 2 + .../2026-07-04-doc-tiers-and-budgets.zh.md | 28 +++ ...-07-04-generate-rfc-index-tables.i18n.yaml | 6 + .../2026-07-04-generate-rfc-index-tables.md | 2 + ...2026-07-04-generate-rfc-index-tables.zh.md | 34 +++ ...26-07-04-persistence-log-catalog.i18n.yaml | 6 + .../2026-07-04-persistence-log-catalog.md | 2 + .../2026-07-04-persistence-log-catalog.zh.md | 36 +++ .../2026-07-05-uniform-rfc-format.i18n.yaml | 6 + .../process/2026-07-05-uniform-rfc-format.md | 2 + .../2026-07-05-uniform-rfc-format.zh.md | 30 +++ ...-07-06-export-surface-jsdoc-gate.i18n.yaml | 6 + .../2026-07-06-export-surface-jsdoc-gate.md | 2 + ...2026-07-06-export-surface-jsdoc-gate.zh.md | 45 ++++ ...6-07-06-generated-config-catalog.i18n.yaml | 6 + .../2026-07-06-generated-config-catalog.md | 2 + .../2026-07-06-generated-config-catalog.zh.md | 40 ++++ .../2026-07-06-node-engine-floor.i18n.yaml | 6 + .../process/2026-07-06-node-engine-floor.md | 2 + .../2026-07-06-node-engine-floor.zh.md | 39 ++++ ...6-07-06-parallel-github-ci-gates.i18n.yaml | 6 + .../2026-07-06-parallel-github-ci-gates.md | 2 + .../2026-07-06-parallel-github-ci-gates.zh.md | 41 ++++ ...26-07-06-parallel-pre-push-gates.i18n.yaml | 6 + .../2026-07-06-parallel-pre-push-gates.md | 2 + .../2026-07-06-parallel-pre-push-gates.zh.md | 42 ++++ ...10-readme-known-limitations-gate.i18n.yaml | 6 + ...026-07-10-readme-known-limitations-gate.md | 2 + ...-07-10-readme-known-limitations-gate.zh.md | 29 +++ ...ackage-model-experience-contract.i18n.yaml | 6 + ...07-12-package-model-experience-contract.md | 2 + ...12-package-model-experience-contract.zh.md | 33 +++ ...-19-drop-mutable-session-summary.i18n.yaml | 6 + ...2026-06-19-drop-mutable-session-summary.md | 2 + ...6-06-19-drop-mutable-session-summary.zh.md | 35 +++ ...llapse-trace-only-session-events.i18n.yaml | 6 + ...6-20-collapse-trace-only-session-events.md | 2 + ...0-collapse-trace-only-session-events.zh.md | 44 ++++ ...onsumed-llm-adapter-change-event.i18n.yaml | 6 + ...rop-unconsumed-llm-adapter-change-event.md | 2 + ...-unconsumed-llm-adapter-change-event.zh.md | 36 +++ ...nconsumed-llm-assembled-surfaces.i18n.yaml | 6 + ...-drop-unconsumed-llm-assembled-surfaces.md | 2 + ...op-unconsumed-llm-assembled-surfaces.zh.md | 39 ++++ ...26-06-20-prune-dead-seam-methods.i18n.yaml | 6 + .../2026-06-20-prune-dead-seam-methods.md | 2 + .../2026-06-20-prune-dead-seam-methods.zh.md | 43 ++++ ...-06-20-public-agent-stop-surface.i18n.yaml | 6 + .../2026-06-20-public-agent-stop-surface.md | 2 + ...2026-06-20-public-agent-stop-surface.zh.md | 39 ++++ ...ove-agent-boundary-mirror-events.i18n.yaml | 6 + ...-20-remove-agent-boundary-mirror-events.md | 2 + ...-remove-agent-boundary-mirror-events.zh.md | 32 +++ .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 6 + .../2026-06-26-fsspec-style-fs-seam.md | 2 + .../2026-06-26-fsspec-style-fs-seam.zh.md | 130 +++++++++++ ...07-02-remove-stream-chunk-mirror.i18n.yaml | 6 + .../2026-07-02-remove-stream-chunk-mirror.md | 2 + ...026-07-02-remove-stream-chunk-mirror.zh.md | 47 ++++ ...6-07-04-drop-image-content-block.i18n.yaml | 6 + .../2026-07-04-drop-image-content-block.md | 2 + .../2026-07-04-drop-image-content-block.zh.md | 29 +++ ...6-07-04-drop-inert-request-knobs.i18n.yaml | 6 + .../2026-07-04-drop-inert-request-knobs.md | 2 + .../2026-07-04-drop-inert-request-knobs.zh.md | 35 +++ ...consumed-web-observation-surface.i18n.yaml | 6 + ...drop-unconsumed-web-observation-surface.md | 2 + ...p-unconsumed-web-observation-surface.zh.md | 34 +++ .../2026-07-04-fold-stdio-ui-helper.i18n.yaml | 6 + .../2026-07-04-fold-stdio-ui-helper.md | 2 + .../2026-07-04-fold-stdio-ui-helper.zh.md | 28 +++ ...producerless-vocabulary-variants.i18n.yaml | 6 + ...-prune-producerless-vocabulary-variants.md | 2 + ...une-producerless-vocabulary-variants.zh.md | 33 +++ ...7-04-prune-write-only-fs-surface.i18n.yaml | 6 + .../2026-07-04-prune-write-only-fs-surface.md | 2 + ...26-07-04-prune-write-only-fs-surface.zh.md | 32 +++ ...-04-remove-agent-steering-mirror.i18n.yaml | 6 + ...2026-07-04-remove-agent-steering-mirror.md | 2 + ...6-07-04-remove-agent-steering-mirror.zh.md | 33 +++ ...26-07-04-share-app-bin-boot-glue.i18n.yaml | 6 + .../2026-07-04-share-app-bin-boot-glue.md | 2 + .../2026-07-04-share-app-bin-boot-glue.zh.md | 27 +++ ...4-tighten-hook-protocol-contract.i18n.yaml | 6 + ...26-07-04-tighten-hook-protocol-contract.md | 2 + ...07-04-tighten-hook-protocol-contract.zh.md | 32 +++ ...m-acp-bridge-unreachable-surface.i18n.yaml | 6 + ...-04-trim-acp-bridge-unreachable-surface.md | 2 + ...-trim-acp-bridge-unreachable-surface.zh.md | 26 +++ ...unconsumed-skill-provider-events.i18n.yaml | 6 + ...2-drop-unconsumed-skill-provider-events.md | 2 + ...rop-unconsumed-skill-provider-events.zh.md | 29 +++ ...-12-prune-unused-web-seam-fields.i18n.yaml | 6 + ...2026-07-12-prune-unused-web-seam-fields.md | 2 + ...6-07-12-prune-unused-web-seam-fields.zh.md | 27 +++ ...026-06-11-property-based-testing.i18n.yaml | 6 + .../2026-06-11-property-based-testing.md | 2 + .../2026-06-11-property-based-testing.zh.md | 29 +++ .../2026-06-19-acp-snapshot-tests.i18n.yaml | 6 + .../testing/2026-06-19-acp-snapshot-tests.md | 2 + .../2026-06-19-acp-snapshot-tests.zh.md | 82 +++++++ .../2026-06-19-real-api-e2e-ci.i18n.yaml | 6 + .../testing/2026-06-19-real-api-e2e-ci.md | 2 + .../testing/2026-06-19-real-api-e2e-ci.zh.md | 100 ++++++++ ...e-redundant-snapshot-log-goldens.i18n.yaml | 6 + ...0-remove-redundant-snapshot-log-goldens.md | 2 + ...emove-redundant-snapshot-log-goldens.zh.md | 37 +++ ...-fork-child-replay-seed-boundary.i18n.yaml | 6 + ...6-06-22-fork-child-replay-seed-boundary.md | 2 + ...6-22-fork-child-replay-seed-boundary.zh.md | 49 ++++ ...26-06-22-fork-snapshot-scenarios.i18n.yaml | 6 + .../2026-06-22-fork-snapshot-scenarios.md | 2 + .../2026-06-22-fork-snapshot-scenarios.zh.md | 31 +++ ...6-06-22-subagent-snapshot-replay.i18n.yaml | 6 + .../2026-06-22-subagent-snapshot-replay.md | 2 + .../2026-06-22-subagent-snapshot-replay.zh.md | 58 +++++ .../2026-07-04-hook-snapshot-matrix.i18n.yaml | 6 + .../2026-07-04-hook-snapshot-matrix.md | 2 + .../2026-07-04-hook-snapshot-matrix.zh.md | 50 ++++ ...-single-source-acp-replay-config.i18n.yaml | 6 + ...6-07-04-single-source-acp-replay-config.md | 2 + ...7-04-single-source-acp-replay-config.zh.md | 27 +++ ...t-header-content-in-one-scenario.i18n.yaml | 6 + ...-request-header-content-in-one-scenario.md | 2 + ...quest-header-content-in-one-scenario.zh.md | 35 +++ ...7-08-shared-acp-snapshot-package.i18n.yaml | 6 + .../2026-07-08-shared-acp-snapshot-package.md | 2 + ...26-07-08-shared-acp-snapshot-package.zh.md | 38 ++++ .../2026-06-16-typed-event-schemas.i18n.yaml | 6 + .../2026-06-16-typed-event-schemas.md | 2 + .../2026-06-16-typed-event-schemas.zh.md | 77 +++++++ ...eneric-long-running-tool-runtime.i18n.yaml | 6 + ...06-20-generic-long-running-tool-runtime.md | 2 + ...20-generic-long-running-tool-runtime.zh.md | 43 ++++ ...026-06-30-pre-tool-input-rewrite.i18n.yaml | 6 + .../2026-06-30-pre-tool-input-rewrite.md | 2 + .../2026-06-30-pre-tool-input-rewrite.zh.md | 53 +++++ ...code-and-codex-subagent-backends.i18n.yaml | 6 + ...claude-code-and-codex-subagent-backends.md | 2 + ...ude-code-and-codex-subagent-backends.zh.md | 89 ++++++++ ...-07-08-interactive-side-sessions.i18n.yaml | 6 + .../2026-07-08-interactive-side-sessions.md | 2 + ...2026-07-08-interactive-side-sessions.zh.md | 41 ++++ ...10-sqlite-session-query-provider.i18n.yaml | 6 + ...026-07-10-sqlite-session-query-provider.md | 2 + ...-07-10-sqlite-session-query-provider.zh.md | 53 +++++ ...flow-progress-through-tool-calls.i18n.yaml | 6 + ...am-workflow-progress-through-tool-calls.md | 2 + ...workflow-progress-through-tool-calls.zh.md | 43 ++++ ...2026-06-11-api-extractor-reports.i18n.yaml | 6 + .../2026-06-11-api-extractor-reports.md | 2 + .../2026-06-11-api-extractor-reports.zh.md | 32 +++ ...-06-11-architectural-conformance.i18n.yaml | 6 + .../2026-06-11-architectural-conformance.md | 2 + ...2026-06-11-architectural-conformance.zh.md | 36 +++ ...11-supply-chain-and-vendor-drift.i18n.yaml | 6 + ...026-06-11-supply-chain-and-vendor-drift.md | 2 + ...-06-11-supply-chain-and-vendor-drift.zh.md | 35 +++ ...06-20-discover-package-inventory.i18n.yaml | 6 + .../2026-06-20-discover-package-inventory.md | 2 + ...026-06-20-discover-package-inventory.zh.md | 36 +++ ...06-20-unify-agent-and-session-id.i18n.yaml | 6 + .../2026-06-20-unify-agent-and-session-id.md | 2 + ...026-06-20-unify-agent-and-session-id.zh.md | 42 ++++ ...04-prune-dead-core-spine-surface.i18n.yaml | 6 + ...026-07-04-prune-dead-core-spine-surface.md | 2 + ...-07-04-prune-dead-core-spine-surface.zh.md | 62 +++++ ...plify-session-log-representation.i18n.yaml | 6 + ...-12-simplify-session-log-representation.md | 2 + ...-simplify-session-log-representation.zh.md | 38 ++++ ...deterministic-and-stress-testing.i18n.yaml | 6 + ...-06-11-deterministic-and-stress-testing.md | 2 + ...-11-deterministic-and-stress-testing.zh.md | 33 +++ .../2026-06-11-mutation-testing.i18n.yaml | 6 + .../testing/2026-06-11-mutation-testing.md | 2 + .../testing/2026-06-11-mutation-testing.zh.md | 36 +++ ...-06-11-immutable-public-surfaces.i18n.yaml | 6 + .../2026-06-11-immutable-public-surfaces.md | 2 + ...2026-06-11-immutable-public-surfaces.zh.md | 29 +++ ...-06-20-providerless-example-base.i18n.yaml | 6 + .../2026-06-20-providerless-example-base.md | 2 + ...2026-06-20-providerless-example-base.zh.md | 31 +++ ...ssembled-assistant-messages-only.i18n.yaml | 6 + ...06-20-assembled-assistant-messages-only.md | 2 + ...20-assembled-assistant-messages-only.zh.md | 36 +++ ...2026-06-20-drop-acp-session-load.i18n.yaml | 6 + .../2026-06-20-drop-acp-session-load.md | 2 + .../2026-06-20-drop-acp-session-load.zh.md | 29 +++ ...026-06-20-drop-acp-terminal-meta.i18n.yaml | 6 + .../2026-06-20-drop-acp-terminal-meta.md | 2 + .../2026-06-20-drop-acp-terminal-meta.zh.md | 31 +++ ...-20-drop-bash-output-spill-files.i18n.yaml | 6 + ...2026-06-20-drop-bash-output-spill-files.md | 2 + ...6-06-20-drop-bash-output-spill-files.zh.md | 31 +++ ...-20-drop-durable-step-boundaries.i18n.yaml | 6 + ...2026-06-20-drop-durable-step-boundaries.md | 2 + ...6-06-20-drop-durable-step-boundaries.zh.md | 32 +++ ...6-20-drop-unused-session-lineage.i18n.yaml | 6 + .../2026-06-20-drop-unused-session-lineage.md | 2 + ...26-06-20-drop-unused-session-lineage.zh.md | 31 +++ ...ld-session-persistence-interface.i18n.yaml | 6 + ...6-20-fold-session-persistence-interface.md | 2 + ...0-fold-session-persistence-interface.zh.md | 31 +++ ...026-06-20-generic-tool-rendering.i18n.yaml | 6 + .../2026-06-20-generic-tool-rendering.md | 2 + .../2026-06-20-generic-tool-rendering.zh.md | 35 +++ ...6-06-20-retire-mid-turn-steering.i18n.yaml | 6 + .../2026-06-20-retire-mid-turn-steering.md | 2 + .../2026-06-20-retire-mid-turn-steering.zh.md | 37 +++ ...-06-20-single-session-acp-bridge.i18n.yaml | 6 + .../2026-06-20-single-session-acp-bridge.md | 2 + ...2026-06-20-single-session-acp-bridge.zh.md | 31 +++ ...06-20-truncate-interrupted-turns.i18n.yaml | 6 + .../2026-06-20-truncate-interrupted-turns.md | 2 + ...026-06-20-truncate-interrupted-turns.zh.md | 36 +++ ...nimplemented-subagent-vocabulary.i18n.yaml | 6 + ...prune-unimplemented-subagent-vocabulary.md | 2 + ...ne-unimplemented-subagent-vocabulary.zh.md | 39 ++++ ...apse-workflow-to-foreground-core.i18n.yaml | 6 + ...12-collapse-workflow-to-foreground-core.md | 2 + ...collapse-workflow-to-foreground-core.zh.md | 39 ++++ ...ne-unused-skill-registry-surface.i18n.yaml | 6 + ...-12-prune-unused-skill-registry-surface.md | 2 + ...-prune-unused-skill-registry-surface.zh.md | 29 +++ scripts/translation-pairing.manifest.json | 148 ++++++++++++ 439 files changed, 8800 insertions(+) create mode 100644 docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md create mode 100644 docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md create mode 100644 docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md create mode 100644 docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md create mode 100644 docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml create mode 100644 docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md create mode 100644 docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md create mode 100644 docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md create mode 100644 docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md create mode 100644 docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md create mode 100644 docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md create mode 100644 docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md create mode 100644 docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md create mode 100644 docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md create mode 100644 docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md create mode 100644 docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md create mode 100644 docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md create mode 100644 docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml create mode 100644 docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md create mode 100644 docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml create mode 100644 docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md create mode 100644 docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md create mode 100644 docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md create mode 100644 docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md create mode 100644 docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md create mode 100644 docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md create mode 100644 docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml create mode 100644 docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md create mode 100644 docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml create mode 100644 docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md create mode 100644 docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml create mode 100644 docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md create mode 100644 docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml create mode 100644 docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md create mode 100644 docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md create mode 100644 docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md create mode 100644 docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md create mode 100644 docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml create mode 100644 docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md create mode 100644 docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml create mode 100644 docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md create mode 100644 docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml create mode 100644 docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md create mode 100644 docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml create mode 100644 docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md create mode 100644 docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml new file mode 100644 index 0000000000..67537fda94 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-content-block-vocabulary.md: 9414bda624fa6e5fc7e9b11b7a738d32b269af6b +2026-06-11-content-block-vocabulary.zh.md: 32308a5fe58f3a2e3c402982b7067811b9698218 diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 1c22f9b7ca..9414bda624 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -1,5 +1,7 @@ # RFC: Provider-neutral content-block vocabulary owned by dsh-llm +English | [中文](2026-06-11-content-block-vocabulary.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md new file mode 100644 index 0000000000..32308a5fe5 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -0,0 +1,28 @@ +# RFC:由 dsh-llm 持有的提供方无关内容块词汇 + +Status: implemented + +[English](2026-06-11-content-block-vocabulary.md) | 中文 + +## 问题 + +harness 需要一套统一的内部消息语言,供 agent loop(智能体循环)、会话日志和所有插件共同使用。 + +## 决策 + +自行持有词汇:消息是类型化内容块(`text`、`reasoning`、`tool-call`、`tool-result`)的数组,其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一套可合并扩展映射模式也用于所有「字符串化」字段的类型定义(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出是原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为各提供方的协议格式(wire format):映射成本留在适配器中,这正是它该待的地方。 + +会话内上下文注入(`context/message`、`steering/message`)渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新 role,因此适配器零负担。真实适配器验证已确认该渲染方式在当前 DeepSeek 行为下有效;如果未来某个提供方出现不匹配,应在该适配器内处理,而非引入新的规范 role。 + +## 曾考虑的替代方案 + +- **镜像 DeepSeek/OpenAI chat-completions 的结构**:对第一个提供方零映射成本,但对富内容(推理(reasoning)、作为结构化块的工具结果)处理起来别扭。 +- **原样采用 Anthropic Messages 的块结构**:经过实战检验,但规范类型将镜像一个 harness 并非首要对接的第三方 API。 + +## 后果 + +- 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 +- 多模态块只有在适配器、UI 与上下文压缩(context compaction)三方协同支持时才会回归;见[移除 image 内容块 RFC](../simplification/2026-07-04-drop-image-content-block.md)。 +- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的变体](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[惰性请求旋钮](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。 +- 每个适配器都要承担翻译成本;首批真实适配器已验证了流式输出协议,后续新适配器应继续在适配器本地测试中证明其提供方特有的映射。 +- 跨包边界的 ID 使用品牌类型(`CallId`、`SessionId`、`AgentId`):零运行时成本的名义类型。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml new file mode 100644 index 0000000000..5046fe3a38 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-custom-schema-dsl.md: 4c4d572b15d5e474e00e99fc5e7dd89240251c63 +2026-06-11-custom-schema-dsl.zh.md: 31af594846b6b1bc8ce983b7b9d4ddd956a560ff diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md index 18923fbb60..4c4d572b15 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md @@ -1,5 +1,7 @@ # RFC: Custom typed tool-schema DSL instead of schemastery +English | [中文](2026-06-11-custom-schema-dsl.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md new file mode 100644 index 0000000000..31af594846 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md @@ -0,0 +1,23 @@ +# RFC:使用自定义类型化工具 schema DSL 替代 schemastery + +Status: implemented + +[English](2026-06-11-custom-schema-dsl.md) | 中文 + +## 问题 + +工具参数必须以标准 JSON Schema 的形式传递给模型,同时让工具作者在 `execute(args)` 中获得类型推导而无需类型断言。schemastery 已用于插件配置,但工具作者 API 需要的是逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。 + +## 决策 + +在 dsh-tools 中实现一个小型自定义 DSL:`SchemaSpec`(逐属性规格,带 `required: true` 布尔值);类型层面的 `InferArgs<S>` 将规格映射为参数类型(required 键为必选,其余通过 `?` 真正可选);运行时的 `schemaSpecToJsonSchema()` 转换器;以及将它们串联起来的 `defineTool()`。`ToolRegistry.register()` 仍接受原始 JSON Schema 的 `ToolDefinition`——MCP 来源的工具就是这样注册的。 + +## 曾考虑的替代方案 + +**schemastery**(已 vendor、用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。 + +## 后果 + +- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。 +- DSL 刻意保持小巧(string/number/boolean/object/array、enum、default、嵌套 properties/items)。相对完整 JSON Schema 的缺口(union、format、约束)在真实工具提出需求之前暂不填补。 +- `InferArgs` 映射在一次早期可选性 bug 之后已有类型层面的回归测试。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml new file mode 100644 index 0000000000..ef2f73d949 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-dev-invariants-over-deep-readonly.md: dc89b5b66d02bde2f4fe2794e04b76ecdeac62ae +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 02beb323df84cd442611f0c52b3c56734d6d0554 diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index aac3991f46..dc89b5b66d 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -1,5 +1,7 @@ # RFC: Source-owned session immutability and dev-mode invariants +English | [中文](2026-06-11-dev-invariants-over-deep-readonly.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md new file mode 100644 index 0000000000..02beb323df --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -0,0 +1,60 @@ +# RFC:源头拥有的会话不可变性与开发模式不变式 + +Status: implemented + +[English](2026-06-11-dev-invariants-over-deep-readonly.md) | 中文 + +## 问题 + +会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实间关系的检查。如果将二者混为一体放进一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。 + +会话日志是回放、请求重建、持久化和用户可见历史的持久真源。会话包以外的代码必须能够检视该历史,但不能保留一个可以事后改写它的引用;从调用方接收的输入也不能继续连接到调用方拥有的可变对象上。 + +单个值的不可变性只是契约的一半。一份日志可以包含完全不可变的记录,但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务,无法通过冻结单个对象来建立。 + +TypeScript readonly 类型不构成充分的运行时边界。它们在程序运行时消失,一次类型转换即可绕过,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。 + +## 决策 + +职责在一个始终开启的存储边界与可选的开发断言之间分离。 + +### Session 拥有不可变历史 + +`Session` 仅在一次递归遍历完成无损 JSON 快照后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切脱离记录,因此校验和存储不可能从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 + +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者收到同一条记录,`session.events` 返回一份冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的校验、快照与冻结边界。 + +这一保证属于 `Session` 而非可选的监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 + +### 派生请求保持脱离 + +`deriveMessages()` 将已记录的表面事件投影为脱离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。请求组装因此可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。 + +### 不变式插件检查关系 + +`dsh-invariants` 是一个纯监听的开发插件。它不冻结记录,没有配置;dispose 仅移除其断言。它检查需要追踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent 状态转换、主体正确的作用域分发,以及 agent loop 构建的请求与从其会话日志前缀重建的请求之间的等价性。 + +当插件附加到已有或已播种的会话时,它回放不可变日志以重建追踪状态。这使得在轮次中间进行热重载是安全的,同时不赋予插件对会话存储的所有权。 + +## 曾考虑的替代方案 + +### 全面的 deep-readonly 类型 + +[被否决的不可变公开表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公开的日志和消息表面全面应用递归 readonly 类型。这能提供编辑器反馈,但不能提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。 + +### 仅在开发模式冻结 + +仅在安装了不变式插件时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终开启,而更昂贵的关系检查保持为可选的开发支持。 + +### 仅在派生消息时克隆 + +脱离 `deriveMessages()` 会保护最常见的请求路径,但 `session.events` 的其他读者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,不是替代品。 + +## 后果 + +- 每条被接受的实时或种子会话事件在任何观察者收到之前,都已从调用方拥有的输入中脱离并深度不可变。 +- `session.events` 暴露稳定的不可变快照,而非私有的增长数组。 +- 请求侧的修改无法通过派生消息触及已存储的历史。 +- 开发构建可以启用关系断言而不改变存储行为;dispose 或省略该插件不会削弱日志不可变性。 +- `dsh-invariants` 没有 `Config` 表面,因为它没有可调节的行为。 +- 运行时边界在每条被接受的事件上承担一次递归快照与冻结的开销;后续读者和缓存投影复用已拥有的不可变记录。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml new file mode 100644 index 0000000000..00e6dc9181 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-event-sourced-sessions.md: 04ff974826ffbc9052c7eb9f5794bc16557241f9 +2026-06-11-event-sourced-sessions.zh.md: a3fec18445673bc2368333413f9802f9822b9c89 diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md index 4539fb40ba..04ff974826 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -1,5 +1,7 @@ # RFC: Event-sourced sessions with derived message history +English | [中文](2026-06-11-event-sourced-sessions.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md new file mode 100644 index 0000000000..a3fec18445 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -0,0 +1,28 @@ +# RFC:事件溯源的会话与派生消息历史 + +Status: implemented + +[English](2026-06-11-event-sourced-sessions.md) | 中文 + +## 问题 + +MVP 要求严格的基于事件的 trace、logging 系统,session 完全可回放。 + +## 决策 + +`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志,是唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`);原始流分片被记入日志以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威来源。回放/fork = 用已有日志初始化一个新会话。 + +追加操作是同步的(热路径从不阻塞在 I/O 上);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。 + +顺序契约:agent loop(智能体循环)先追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。 + +## 曾考虑的替代方案 + +**可变消息数组 + 事件作为通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身就是状态,分歧在结构上不可能发生。 + +## 后果 + +- 回放、trace 与遥测在结构上得到保证,而非事后附加。 +- 持久化仍是插件关注点;内存存储随 dsh-session 一起发布。 +- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志变为持久后冻结了其形状。 +- 派生成本随日志长度增长——压缩(未来插件)是预期的缓解手段,而非日志变更。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml new file mode 100644 index 0000000000..00320a6991 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-microkernel-event-taxonomy.md: c66968257a5a6304f187ccb5b9a162aa143e608d +2026-06-11-microkernel-event-taxonomy.zh.md: 86750f6ece488e735f2c14f759db7c9eef360828 diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 8293924d37..c66968257a 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -1,5 +1,7 @@ # RFC: Microkernel — extension via Cordis event taxonomy, one concrete loop +English | [中文](2026-06-11-microkernel-event-taxonomy.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md new file mode 100644 index 0000000000..86750f6ece --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -0,0 +1,31 @@ +# RFC:微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环 + +Status: implemented + +[English](2026-06-11-microkernel-event-taxonomy.md) | 中文 + +## 问题 + +产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩(context compaction)、沙箱、权限、UI、持久化、MCP、skill(技能)都必须能以插件形式编写,而无需修改核心。 + +## 决策 + +纯 Cordis 事件分类体系(taxonomy)。循环的扩展 seam 是带有明确分发模式的类型化事件: + +- **waterfall(瀑布式事件)**(around-middleware):插件可以变换、否决或包装:`agent/prompt-submit`、`agent/request`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 +- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时都会运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 +- **parallel**(await 扇出):每个监听器都必须获得独立执行机会:`session/flush` 持久性检查点。 +- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流式分片、生命周期、错误,以及包含不可变 `tools/result` 观测值的事件。 + +事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且本身可替换——它之外的任何代码都不得依赖它。 + +## 曾考虑的替代方案 + +**专用中间件栈(koa-compose 风格)** 与 **插件插入其中的显式阶段状态机**:两者都需要重新实现分发、dispose(资源释放)和重载语义,而 Cordis 原生事件系统已经提供了这些;作为 Cordis effect,监听器天然获得 HMR(热模块替换)和 dispose 能力。 + +## 后果 + +- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持最新)。 +- HMR 和 dispose 免费获得:监听器和注册都是 Cordis effect。 +- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——已在 AGENTS.md 中记录,并由组合测试覆盖。 +- 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何 seam 的 steering(中途引导)绝不会被搁置(有回归测试保障)。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml new file mode 100644 index 0000000000..8e7b36b58d --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-runtime-arg-validation.md: 6da117643166d304bee1d368a314cc1602cac828 +2026-06-11-runtime-arg-validation.zh.md: fb67568da3bfc7f1f8fcc0429b6c68193e04693a diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md index 33bf241c74..6da1176431 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md @@ -1,5 +1,7 @@ # RFC: Runtime arg validation at the model boundary +English | [中文](2026-06-11-runtime-arg-validation.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md new file mode 100644 index 0000000000..fb67568da3 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -0,0 +1,24 @@ +# RFC:模型边界处的运行时参数校验 + +Status: implemented + +[English](2026-06-11-runtime-arg-validation.md) | 中文 + +## 问题 + +`defineTool`([自定义 schema DSL](2026-06-11-custom-schema-dsl.md))通过 `InferArgs<S>` 映射为工具作者提供了类型化的 `execute(args)`。但该类型只是编译期对一个运行时值的声明:这个值以模型生成的 JSON 形式到达,没有任何机制强制模型遵守 schema。因此,一次格式错误的调用(缺少必填键、声明为数字的位置传入字符串、枚举值超出集合)会以「仅有类型之名」的状态抵达 `execute`。工具体要么在错误形状上崩溃(产生一条模型无法据以行动的通用堆栈跟踪),要么更糟:静默地行为异常。与此同时,转换器已经编码了校验器遍历所需的完整结构。 + +## 决策 + +`validateArgs(spec, args): string[]` 对一个运行时值解释 `SchemaSpec`,返回人类可读的违规列表(空 = 合法),且是全函数(从不抛出异常)。`defineTool` 在调用类型化的工具体之前运行它;如果存在违规,则抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`,消息列出违规项),注册表既有的 execute-waterfall catch 将其转为模型可读取并据以自我修正的 `isError` 结果。 + +校验器严格镜像 `schemaSpecToJsonSchema` 的语义:遍历相同的结构、执行相同的规则:顶层必须是非数组对象;必填键仅来自 `required: true`;允许额外键(不设 `additionalProperties: false`);不应用 `default`;没有 `properties`/`items` 的 `object`/`array` 属性仅做类型检查;`enum` 是成员判定。原始注册的(MCP)工具不受影响:它们自行校验输入。 + +## 后果 + +- 模型在自身格式错误的调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。 +- 校验器与 `InferArgs` 必须保持一致;一组[属性测试](../testing/2026-06-11-property-based-testing.md)会生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。 +- `ToolArgsError` 目前是一个带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,而不影响读取 `.message` 的调用方。 +- 校验开销相对于一次模型调用可忽略不计。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml new file mode 100644 index 0000000000..84528dccf1 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-structured-error-taxonomy.md: 2baf88a1f942215e79561e565f455276c80178c4 +2026-06-11-structured-error-taxonomy.zh.md: f4762c4e92fb94c5bdbccc5f9a61e1496dc4c66d diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md index 01e50da2ff..2baf88a1f9 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md @@ -1,5 +1,7 @@ # RFC: Structured error taxonomy +English | [中文](2026-06-11-structured-error-taxonomy.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md new file mode 100644 index 0000000000..f4762c4e92 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md @@ -0,0 +1,26 @@ +# RFC:结构化错误分类体系 + +Status: implemented + +[English](2026-06-11-structured-error-taxonomy.md) | 中文 + +## 问题 + +错误跨越服务边界时只是裸字符串。工具错误被扁平化为一个文本块——name、code 和 stack 全部丢失——导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES,模型得到的反馈也不如本可以获得的那样可操作。非 Error 的 throw 退化得更严重:agent loop(智能体循环)将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对一个通用基类做 `instanceof`。 + +## 决策 + +在 `dsh-llm`(叶子包(package),所有其他包都已依赖它——不引入新的依赖边)中建立一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 的 `cause` 链式传递、`name` 默认为子类名。`isHarnessError` 在服务边界处做类型收窄。 + +- `LlmError`、`ToolArgsError`(dsh-tools)和 `InvariantError`(dsh-invariants)现在继承该基类,保留各自既有的 code。 +- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存入日志,供重试/沙箱插件和回放使用。面向模型的文本块不变。 +- agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError`(`code: 'UNKNOWN'`,原始值通过 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件已暴露 `code`)。 + +## 后果 + +- 错误在端到端链路上可被机器路由:插件可以按 `error.code` 分支,而非对 message 做子串匹配。 +- 一个基类被广泛导入,但它位于所有包本已依赖的包中,代价只是一条 import 语句,而非一条新的依赖边。 +- `deriveMessages` 不会将 `error` 字段呈现到模型历史中——模型仍然看到文本块;结构化字段服务于代码逻辑和回放。 +- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类添加了跨服务边界的路由元数据,不改变面向模型的文本。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml new file mode 100644 index 0000000000..c0625866a3 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-tool-schemas-in-prompt-assembly.md: 443e6f20115e5a76001b4466c2d756675adbd886 +2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 2d23d6fd0ead17fbeed3feaf3cec864813ef47e4 diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md index 5c78ef4280..443e6f2011 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md @@ -1,5 +1,7 @@ # RFC: Tool schemas are part of the system-prompt assembly +English | [中文](2026-06-11-tool-schemas-in-prompt-assembly.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md new file mode 100644 index 0000000000..2d23d6fd0e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md @@ -0,0 +1,23 @@ +# RFC:工具 schema 属于系统提示词组装的一部分 + +Status: implemented + +[English](2026-06-11-tool-schemas-in-prompt-assembly.md) | 中文 + +## 问题 + +在协议格式(wire format)层面,工具 schema 通过模型请求中专用的 `tools` 字段传输,而非嵌入提示词文本。但从架构角度看,「模型被告知它能做什么」是一个内聚的关注点:提示词段落和工具列表由同一批插件贡献组装而成,并在同一时刻被消费。 + +## 决策 + +`PromptAssembly { sections, tools }`:系统提示词服务同时收集有序的文本段落和工具 schema(工具注册表自动贡献一个提供方)。agent loop(智能体循环)每步消费一个 assembly;适配器将 `sections` 映射到提供方的 system 槽位,将 `tools` 映射到协议格式的 `tools` 字段。因此 `system-prompt/assemble` waterfall(瀑布式事件)是模型前置信息的唯一拦截点——工具过滤(ToolSearch / 渐进式披露)是一次 assembly 改写,与提示词编辑无异。 + +## 曾考虑的替代方案 + +**循环分别向工具注册表和提示词服务查询**——将一个内聚的关注点拆到两个 seam 上;任何想塑造「模型被告知什么」的拦截(工具过滤、plan 模式)都需要在两个接口上各挂一个监听器,而非一次 assembly 改写。 + +## 后果 + +- 一条 waterfall 统管模型的常驻上下文;plan 模式等插件可以在一个监听器中同时替换提示词文本和可见工具。 +- assembly 接口通过声明合并实现可扩展(无需无类型的 `extras` 包——扩展即声明合并)。 +- 「schema 出现在提示词服务中」有轻微的概念意外感,本文与 package README 对此做了说明。 diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml new file mode 100644 index 0000000000..25e7f01d0f --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-13-capability-seams.md: e9d417dbd2bafcaece39601b12dbb310feb1e19b +2026-06-13-capability-seams.zh.md: e5d9be803f71ef3b85f79ed483dab64612a27fc7 diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md index 907e4cd86b..e9d417dbd2 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md @@ -1,5 +1,7 @@ # RFC: Capability seams — interface / implementation / consumer split +English | [中文](2026-06-13-capability-seams.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md new file mode 100644 index 0000000000..e5d9be803f --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -0,0 +1,32 @@ +# RFC:能力 seam——接口/实现/消费方拆分 + +Status: implemented + +[English](2026-06-13-capability-seams.md) | 中文 + +## 问题 + +harness 具有可替换的能力:目前是 bash 执行,未来会有沙箱/远程执行器和替代模型提供方。一项能力有三个关注点,它们以不同的速率、出于不同的原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面对什么来编程)。将三者打包在一个 package 中会耦合这些变化速率:把本地执行器换成沙箱执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 + +这与「运行时谁提供、谁需要一项能力」是不同的问题,后者 Cordis 已经用 service + `inject` 回答了(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到该服务存在)。那套机制是必要的,但它不决定 package 边界;本 RFC 决定。 + +## 决策 + +一项可替换的能力拆为**三个 package**: + +1. **接口**:一个抽象 service 加词汇类型,拥有 `ctx.<key>`,仅依赖 cordis(例如 `dsh-bash`:`BashExecutor`、`BashRunResult`、`BashTask`)。 +2. **实现**:一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、spill-file 截断)。沙箱/远程后端是实现同一接口的兄弟 package。 +3. **消费方**:模型和插件看到的东西(例如 `dsh-tool-bash`:`bash`/`bash_output`/`bash_kill` 工具 schema)。消费方 `inject` 接口 key,从不导入实现类型。 + +实现与消费方随后独立演进:沙箱执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。 + +当各部分确实属于同一关注点时,拆分并非强制:LLM seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 表面),适配器作为实现 package。不要预防性拆分:只有一种可设想的实现和一个消费方的能力保持为一个 package,直到第二个出现。 + +## 曾考虑的替代方案 + +- **合并为一个 package**:否决,因为它重新耦合了拆分所要分离的三种变化速率(这正是拆分的全部意义)。 +- **`@cordisjs/plugin-capability`**:完全不同的维度。它是一个权限/能力*安全*服务(带继承的命名权限,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,而**不是**替换实现的机制。混淆这两个「能力」正是本 RFC 所指出的陷阱。 + +## 后果 + +每项能力多出更多 package 和更多样板代码(一套 `package.json`/`tsconfig`/README,加上 inject 接线)。换来的是:实现与消费方独立发布和版本化,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions("Capability seams are three packages")和 [architecture.md](../../../architecture.md) § "Capability seams" 中;bash 三件套是参考模板。何时合并、何时拆分是一个判断性决策,架构文档已做说明——本 RFC 记录的是*为什么*默认选择拆分。 diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml new file mode 100644 index 0000000000..a46193a7cd --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-13-twin-llm-adapters.md: 4efefcf4e2f6b1d60567ba3bfed7ae1ea53a7a4e +2026-06-13-twin-llm-adapters.zh.md: 806eea84ff3e6c4de988348964429ccaea27f4ba diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md index 0ded28f598..4efefcf4e2 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -1,5 +1,7 @@ # RFC: Two LLM adapters as a design-verification twin +English | [中文](2026-06-13-twin-llm-adapters.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md new file mode 100644 index 0000000000..806eea84ff --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -0,0 +1,27 @@ +# RFC:以两个 LLM 适配器作为设计验证孪生 + +Status: implemented + +[English](2026-06-13-twin-llm-adapters.md) | 中文 + +## 问题 + +`dsh-llm` 拥有一套提供方无关的流式输出词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇只针对单一适配器定义,就有把该适配器的怪癖烘焙进「中立」契约的风险:那个唯一实现碰巧做了什么,就会变成事实上的规范;而抽象在第二个提供方到来之前都无法被验证——届时泄漏已经代价高昂。 + +## 决策 + +从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现: + +- `dsh-llm-deepseek`:手写 `fetch` + SSE 解析,直连 DeepSeek API。 +- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库(有自己的事件词汇)访问同一端点。 + +它们强制执行的规则是:**凡是 StreamChunk 词汇无法同时为两个实现表达的东西,都是核心词汇的 bug**——立即暴露,而非等到下一个提供方才发现。这对孪生确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程为原始 JSON 字符串,以及消费方必须在两侧都处理的两条合法错误路径(从 `stream()` 抛异常,*或*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由库封装的适配器暴露出来的,单一手写适配器会将其掩盖。 + +## 曾考虑的替代方案 + +- **单一适配器**:代码更少、e2e 成本减半,但「提供方无关」的声明无法验证;词汇会默默编码 DeepSeek-via-fetch 的假设。 +- **mock 第二适配器**:更便宜,但不会触及真实提供方的协议格式(wire format)怪癖,因此证明力有限。孪生是真实对真实。 + +## 后果 + +孪生使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理模式下的表现——换来的是对 seam 中立性的持续验证和第二份实现示例。两者都使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来的一致性测试套件可以通过一份取代性 RFC 来论证退役其中一个适配器。 diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml new file mode 100644 index 0000000000..3858139410 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-14-session-persistence.md: 4078487b71862791dd25cf2afcfc03255cfeb0be +2026-06-14-session-persistence.zh.md: 801632b56d2056b36dbf7869f5114d74599bd5d5 diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 327aa8eac3..4078487b71 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -1,5 +1,7 @@ # RFC: Session persistence as an abstract service over the existing `SessionEvent` +English | [中文](2026-06-14-session-persistence.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md new file mode 100644 index 0000000000..801632b56d --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -0,0 +1,36 @@ +# RFC:会话持久化——基于既有 `SessionEvent` 的抽象服务 + +Status: implemented + +[English](2026-06-14-session-persistence.md) | 中文 + +## 问题 + +会话此前只存在于内存中。示例插件 `session-jsonl.ts`(在两个 examples 目录中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、dispose 时 fire-and-forget 地排空缓冲区),没有列表功能,也没有格式版本控制。没有任何东西能把磁盘上的历史会话重新注入一个活跃的 agent,因此持久恢复(「继续昨天的任务」)、持久 fork,以及 ACP 的 `session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都不可能实现。 + +[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM 历史。持久化必须忠于这一点:直接持久化既有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前是文件存储,将来是数据库存储——统一在一个接口之后。 + +## 决策 + +持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-bash` 模板),而非循环或核心逻辑: + +1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是既有的 `SessionEvent`(`{ type, seq, time, data }`),逐字复用,无转换类型。 +2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字保留**包括 `assistant/chunk`**)。 + +以下关键选择记录于此,因为它们是持久的、有争议的、且出人意料的: + +- **规范持久日志逐字保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载校验 `events[i].seq === i` 要求日志*连续*;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。未来可以将过滤 chunk 的投影作为带独立重编号的派生视图,但它不是规范日志。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的 `turn/end` 之前的事件永不重写,且循环只在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 会保留其中连续且可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。这些合成结果使恢复后的 provider transcript 保持有效。只有不完整的最后一条记录会被丢弃;如果在最后一个真实 `turn/end` 或之前出现解析错误或序号间隙,则视为损坏,该会话不可加载。 +- **文件后端为规范实现,数据库后端为已验证的可替换方案。** `SessionEvent` 1:1 映射为一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口不变(opencode 在 SQLite/WAL 上运行的正是这个形状),且它通过与 JSONL 后端相同的 `runPersistenceContract` 套件——因此契约以相同的语义(惰性物化、加载时关闭中断轮次、连续 seq)约束两个后端,一次表达在文件字节上,一次表达在行上。 +- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,通过新的只读属性 `session.header` 附加到 `Session`——永远不在 `SessionEventMap` 中,永远不会到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件在 seed/fork 会话时可以免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因为是死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) +- **`ctx.agents.create()` 与 `ctx.agents.resume()` 是异步工厂;resume 还额外跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 继续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop 不会硬注入 `sessionPersistence`(那会让非持久化的演示永远挂起);当 `sessionPersistence` 不存在时,`resume` 以明确的错误拒绝。 + +## 曾考虑的替代方案 + +上述每个关键选择在陈述时已记录了其被否决的替代方案:**过滤 chunk 的规范日志**(Codex 的 `policy.rs` 形状)——破坏连续 seq 契约;**截断崩溃的轮次**——静默销毁长时间自主运行的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**——会让非持久化的演示永远挂起。 + +格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布的会话格式固定在 `SESSION_FORMAT_VERSION = 0`,按 AGENTS.md 的预发布立场吸收形状变动)。坦率地说:仅追加 + 刷写对部分尾部写入(加载时容忍)是健壮的,但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是将来更强的选项。 + +## 后果 + +新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`、`create(id?, options?)` 签名)。收获:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部建立在既有的事件溯源日志之上,后端可在一个接口后替换。可复用的 `runPersistenceContract` 套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字保留。 diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml new file mode 100644 index 0000000000..545fdae4ff --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-15-turn-enclosure-invariant.md: bf0789f21ba7bd928e023bb5fd844d9ec78c4bc1 +2026-06-15-turn-enclosure-invariant.zh.md: 9f6f69b923ed7feff51122c4e4040bff3c9db8ea diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index e55cd0853e..bf0789f21b 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -1,5 +1,7 @@ # RFC: Every session event is enclosed in a turn +English | [中文](2026-06-15-turn-enclosure-invariant.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md new file mode 100644 index 0000000000..9f6f69b923 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md @@ -0,0 +1,42 @@ +# RFC:每个会话事件必须包含在一个轮次内 + +Status: implemented + +[English](2026-06-15-turn-enclosure-invariant.md) | 中文 + +## 问题 + +持久化的会话持久化后端(在一个配套变更中引入)以**轮次**作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,`load` 会用一个合成的 `turn/end {kind:'interrupted'}` 将其关闭,同时保留该轮次的真实事件(见[会话持久化](2026-06-14-session-persistence.md))。这种恢复只有在没有任何*合法的*持久化事件位于轮次之外(即上一个 `turn/end` 与下一个 `turn/start` 之间的间隙)时才是良定义的,否则这类事件会被裹入下一个轮次的中断关闭中。 + +该假设并不成立。有两条路径在轮次之外记录了事件: + +1. **排队的用户消息。** agent loop(智能体循环)排空排队消息并在 `turn/start` **之前**追加 `user/message`,导致一个轮次自身的提示词落在前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。 +2. **空闲时的上下文注入。** `agent.inject()` 直接追加一条 `context/message`。它在生产环境中的实际调用方是 `dsh-tool-bash`,后者从 `ctx.bash.onTaskDone` 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,经常发生在 agent **空闲**(两个轮次之间)时。 + +对于情况 2,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end`),`scanLog` 会将其视为崩溃残留并**在恢复时丢弃**——注入的上下文虽然已持久化到磁盘,但在重新加载时被静默丢失。情况 1 单独来看是无害的(`user/message` 之后总是紧跟它触发的轮次),但使得「什么可以出现在轮次之外」这条规则变得模糊。 + +## 决策 + +**每个会话事件都位于一个轮次内部**——在一个 `turn/start` 与其匹配的 `turn/end` 之间。具体而言: + +- agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非在其之前。因此,这些消息一经记录,`turn/end` 就已被承诺,而现有的 finalizer 保证了这一点。 +- 在 agent **运行中**调用 `agent.inject()` 时,其 `context/message` 追加到已打开的轮次中(行为不变)。 +- 在 agent **空闲时**调用 `agent.inject()`,系统将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`。 +- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而非维护一个私有计数器,因此空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。 +- `dsh-invariants` 插件在开发模式下**强制执行**该不变式:在没有打开的轮次时追加 `user/message`/`context/message`/`steering/message` 会抛出 `InvariantError`。 + +可序列化性不变式在同一个源码边界强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一处统一管控,而非由下游恰好在监听的某个后端去发现。 + +## 曾考虑的替代方案 + +**放宽读取端而非约束生产端**——让 `scanLog` 提交位于已打开轮次之外的事件。否决:一条可检查的生产端规则优于一条更宽松的边界扫描逻辑,后者需要同时推理部分轮次*和*轮次间的散落事件。 + +## 后果 + +轮次现在是*唯一的*持久化/回放边界,因此[会话持久化](2026-06-14-session-persistence.md)的崩溃恢复规则是完备的,而不仅仅是充分的:一个被中断的最终轮次会被关闭(用合成的 `turn/end {interrupted}`),其真实事件被保留,且完全不存在将轮次间上下文混入其中的风险,因为不再有轮次间上下文。`scanLog` 保持简单(至多一个可能未关闭的最终轮次,永远没有散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后得以存活。 + +代价:空闲时调用 `agent.inject()` 现在写入三行日志而非一行,且推导出的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——`deriveMessages()` 本就纯粹按事件类型推导,因此渲染结果不变。`injection` 触发器是一个新的磁盘词汇值;与每一个 `SessionEventMap`/`TurnTriggerMap` 的新增项一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(`turn/start` 现在先于 `user/message`),这对任何断言旧顺序的代码是可观测的——agent loop 自身的测试是唯一的此类消费方。 + +该规则有意采用生产端强制执行 + 开发模式检查的方式,而非读取端容忍:未来的后端(SQLite/WAL)可以免费继承同样干净的边界,而在轮次外记录事件的插件会在开发模式下大声失败,而非在下次重新加载时静默丢失数据。 + +轮次内检测到的失败在 `turn/end` 之前记录。之后的 flush 失败没有合法的轮次内位置,因此通过 `agent/error` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。 diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml new file mode 100644 index 0000000000..5d92edb79a --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-17-filesystem-capability-seam.md: c502ae712de22e97661192057d4410c7c55ea044 +2026-06-17-filesystem-capability-seam.zh.md: 01a4318237ebbd29fe3effa3f8528b6e4a5132d6 diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 71efeff826..c502ae712d 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -1,5 +1,7 @@ # RFC: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools +English | [中文](2026-06-17-filesystem-capability-seam.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md new file mode 100644 index 0000000000..01a4318237 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -0,0 +1,160 @@ +# RFC:文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具 + +Status: implemented + +[English](2026-06-17-filesystem-capability-seam.md) | 中文 + +## 问题 + +harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read`、`write` 和 `edit` 直接使用 `node:fs`,面向模型的工具包将同时拥有文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。 + +这把三个独立变化的关注点耦合在了一起: + +1. 文件系统契约:插件可以请求哪些操作。 +2. 后端:当前是本地磁盘,未来可能是沙箱/远程/项目范围的文件系统。 +3. 消费方接口:面向模型的 `read` / `write` / `edit` schema 与结果格式化。 + +没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,也会搅动工具 schema、演示和提示词引导。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。 + +我们需要让文件系统工具在成为公开包接口之前,以与 bash 相同的能力 seam 形态落地。 + +## 决策 + +文件系统访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): + +1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。 +2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。 +3. `@deepseek-ai/dsh-tool-fs`(`packages/fs/tool-fs`)通过 `ctx.fs` 提供面向模型的 `read`、`write` 和 `edit` 工具,并作为执行器分发 `fs/*` 事件。 + +消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 + +先读后写/编辑与已观察状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门而非 `ctx.fs` 方法贡献;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得先读后写/编辑能力。本 RFC 确立了三包 seam;策略从提供方基类拆出的决策见 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md),其作为事件门插件(而非方法服务)的实现见 [event-gate RFC](2026-06-26-file-context-as-event-gate.md)。本文已更新为描述最终落地的四包形态。 + +第一个后端刻意仅限本地:`dsh-fs-local` 针对宿主文件系统实现 `ctx.fs`。未来的兄弟后端可以在同一接口后面提供沙箱、远程、虚拟或项目范围的文件系统。 + +第一个消费方刻意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read`、`write` 和 `edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监听或更高层的项目操作,只要所需能力存在于 `ctx.fs` 上,就无需改动本地后端包。直接目录列表后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。 + +文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基础目录解析相对路径,但隔离策略是独立决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 + +先读后写/编辑与已观察状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。详见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 与 [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。 + +## 包拓扑 + +文件系统 seam 使用与 bash 三件套相同的依赖方向: + +```text +@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-local + consumer interface implementation +``` + +`@deepseek-ai/dsh-fs` 仅依赖 `cordis` 和来自 `@deepseek-ai/dsh-llm` 的仓库级 `HarnessError` 基类。它声明 `ctx.fs` 键、抽象 `FileSystem` 服务、后端与消费方共享的词汇类型、文件系统错误词汇,以及 `fs/*` 策略事件词汇。它不持有已观察状态存储,也不持有 owner 推导形态;事件传递一个不透明的 `object` actor,提供方从不读取它,`dsh-fs-policy` 插件在这些事件之上拥有 owner 推导形态和已观察状态存储。 + +`@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs` 和 `cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基础目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有已观察状态存储:新鲜度是后端铸造、策略插件记录的版本令牌。 + +`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 `cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs`、`node:path` 或 `@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs`。 + +根 `tool-fs` 插件通过组合各工具的注册辅助函数来注册完整的文件系统工具套件(`read`、`write` 和 `edit`)。它注入 `fs`,从不导入实现包。 + +## `ctx.fs` 契约 + +`@deepseek-ai/dsh-fs` 拥有一个语义文件系统服务。它比 `readFile` / `writeFile` 更高层,这样 `tool-fs` 就不必重新实现路径解析、版本管理、文本解码、二进制拒绝、分页、原子替换、符号链接行为或字面编辑语义。 + +该接口覆盖以下语义操作: + +- 将模型/插件提供的路径解析为后端定义的目标。 +- 在不读取文件内容的情况下获取目标元数据。 +- 从目标读取有界的 UTF-8 文本页。 +- 创建或替换一个 UTF-8 文本文件。 +- 通过字面替换编辑一个已存在的 UTF-8 文本文件。 + +提供方 seam 还承载策略所依赖的新鲜度钩子,但已观察状态存储和 owner 推导位于 `dsh-fs-policy` 插件中,而非 `ctx.fs` 上: + +- 后端为每个目标铸造一个不透明的 `version` 令牌(在 `stat` 和每次读取/变更结果中)。 +- `writeText`/`editText` 接受一个可选的版本期望:省略它则执行无条件的裸提供方变更,提供它则在后端的原子临界区内守护变更。 +- `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录已观察版本,以从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。 + +授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都记录目标的版本,后续的写入/编辑只要文件仍处于该版本即被授权——因此对第 100-150 行的窗口读取可以授权对第 120 行的编辑。已观察状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。) + +路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目范围的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。 + +解析后的目标必须至少暴露三个概念: + +- 原始输入路径,用于诊断。 +- 不透明的 `targetKey`,用于过期守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 +- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 + +读取和变更结果必须包含一个不透明的文件 `version`。本地后端可以使用 mtime/size 或类 hash 令牌;远程后端可以使用修订 id。`dsh-fs-policy` 插件记录版本用于过期检查;消费方可以展示相关元数据但禁止解释版本令牌。 + +提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 以流式传输相同的文本语义用于大文件。二者都负责常规文件检查;有界行/输出处理不是它们的职责——行窗口、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。 + +已观察状态记录不在 `ctx.fs` 上:成功读取后执行器发出 `fs/observed`,`dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。 + +全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已存在的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并在已存在时以 `FS_NOT_OBSERVED` 拒绝(这是策略为未观察 owner 使用的路径);`replaceIfVersion` 仅在目标处于已观察版本时替换,否则 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的已观察状态选择提供哪个期望。 + +字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的过期版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;过期检查在字面匹配之前运行,因此针对旧读取的编辑会报告 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地式组合。 + +策略插件(而非 `ctx.fs`)对先前观察进行门控:`edit` 要求 owner 有先前观察(否则 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传递给 `editText`。在策略插件缺席时,`ctx.fs` 单独是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。 + +文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码为 `FS_NOT_FOUND`、`FS_NOT_TEXT`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_NOT_REGULAR_FILE`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND` 和 `FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已移除。目录列表相关的错误码后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。) + +## 工具消费方行为 + +`@deepseek-ai/dsh-tool-fs` 是面向模型的消费方。它拥有工具名称、JSON Schema、模型边界的参数校验、提示词段落和结果格式化。它不拥有文件系统执行。 + +第一个工具套件包含: + +- `read`:检查一个 UTF-8 文本文件并返回带行号的内容与分页引导。 +- `write`:创建或完全替换一个 UTF-8 文本文件。 +- `edit`:通过替换字面文本更新一个已存在的 UTF-8 文本文件,默认要求唯一匹配,并允许显式的全部替换模式。 + +每个工具遵循相同的执行形态: + +1. 校验并规范化模型参数。 +2. 调用相应的 `ctx.fs` 操作。 +3. 将结果格式化为面向模型的 `ContentBlock[]`。 +4. 让抛出的后端/工具错误流经 `ToolRegistry.execute()`,由其转换为 `isError` 工具结果。 + +该包通过 `ctx.systemPrompt.section(...)` 注册提示词引导,通过 `ctx.tools.register(...)` 注册 schema。工具 schema 仍通过 `SystemPrompt.assemble()` 和 `ToolRegistry.schemas()` 流入正常的提示词组装路径;无需修改 agent loop。 + +工具包在后端变化时保持面向模型的契约稳定:本地后端和远程后端内部可能以不同方式解析路径,但 `read` / `write` / `edit` 的 schema 不会仅因后端变化而改变。 + +默认部署要求在用 `write` 或 `edit` 更新已存在文件之前先 `read`。`tool-fs` 不通过检查名为 `read` 的工具是否运行过来实现这一点:它分发 `fs/write-intent`/`fs/edit-intent` 事件(将执行上下文作为不透明 actor 传递),`dsh-fs-policy` 插件推导 owner、对先前观察进行门控并提供版本期望。任何窗口读取都能授权后续的写入/编辑,只要文件未变。用 `write` 创建新文件不要求先前观察。 + +根插件通过组合各工具的注册辅助函数来注册完整套件。它注入 `fs`、`tools` 和 `systemPrompt`。 + +## 测试 + +测试遵循包边界,而非仅覆盖用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件与版本守护写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中针对真实本地提供方的消费方接口(仅 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有无 `dsh-fs-policy` 两种情况下的集成测试,通过从磁盘回读文件来验证世界状态,而非信任返回的 `ContentBlock[]`。已观察状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。 + +本仓库曾踩过的防御性模式类别被直接固定: + +- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only(`'wx'`、`0o600`)临时文件暂存,失败时清理,最后原子 rename。这与 bash spill-file 规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限以及已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。 +- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个已观察状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的先读守护,通过一个路径的过期写入可通过另一个路径检测到。 +- **并发/过期竞争。** 两个并发的写入/编辑操作针对同一目标确定性地结算:一个成功,另一个以 `FS_STALE_VERSION` 被拒绝;成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。 +- **HMR 安全与 dispose。** 释放后端的 fiber 会撤回 `ctx.fs` 提供方;后续提供方启动时没有继承的状态。 + +## 曾考虑的替代方案 + +- **面向模型的工具直接使用 `node:fs`**:工具包将同时拥有执行策略、路径解析、原子写入、文本解码和编辑语义,耦合了「问题」一节所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 +- **单一合并包 `dsh-fs-tools`**:seam 之前的形态;出于与 bash 相同的接口/实现/消费方拆分理由被否决,且合并名称从未成为公开接口。 +- **已观察状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观察策略,因此提供方仅保留版本令牌和可选的版本守护变更。 + +## 后果 + +**`cwd` 可能被误认为沙箱。** 本地后端的基础目录是解析默认值,而非自动的隔离边界。如果需要隔离,必须由后端契约或 `tools/execute` 上的权限/沙箱插件强制执行。 + +**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露展示元数据,而不要求消费方理解宿主路径。 + +**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义。这会重新制造本 RFC 试图避免的耦合。 + +**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地结算:一个赢,另一个得到 `FS_STALE_VERSION`。 + +**已观察状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内;split-fs-seam RFC 随后确立:沙箱/远程后端不应继承面向模型的观察策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 仅保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、已观察状态和先读后编辑门控,通过 `fs/*` 事件实现。 + +**`resolve` 后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再作为单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端而言这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每一步变为独立请求,使单次 `read` 变成两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观察契约不变。 + +**已观察状态持久化被推迟。** 已观察状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观察可回放。 + +**错误码成为 seam 的一部分。** `FsError` 错误码使过期版本和观察失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs` 从 `dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且仅限于错误词汇。 + +**包拆分的代价前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改变包接口代价更高。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml new file mode 100644 index 0000000000..85783f6ea4 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-18-agent-lifecycle-and-ownership-seams.md: a70e7db8d809efd68ae770995795fc7b3d1b83d2 +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: ac42a09c70e9570d3def0f0bd056bd571b923315 diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 665063399d..a70e7db8d8 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -1,5 +1,7 @@ # RFC: Agent lifecycle and ownership seams +English | [中文](2026-06-18-agent-lifecycle-and-ownership-seams.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md new file mode 100644 index 0000000000..ac42a09c70 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -0,0 +1,48 @@ +# RFC:Agent 生命周期与所有权 seam + +Status: implemented + +[English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文 + +## 问题 + +ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 seam 的不同症状:插件可以通过 `ctx.agents` 创建或恢复 agent,但无法独立拥有并 dispose(资源释放)单个 agent;长时间运行的 bash 任务在执行器内部也没有稳定的所有者。ACP 在断开连接时中止并等待 agent,却无法只注销该会话的 agent;`session/cancel` 无法取消已排队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。 + +## 决策 + +三个 seam:队列感知的 cancel、`AgentHandle` 释放器,以及 bash 所有者令牌。 + +### 1. 队列感知的 `Agent.cancel(reason?)` + +`cancel()` 是唯一的公开停止原语。它清除已排队的输入和 steering(中途引导)输入,中止正在执行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已排队的提示词在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲 agent 的 cancel 不设置标记。 + +### 2. `AgentHandle` 异步释放器 + +`ctx.agents.create`/`resume` 与 `AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法拆除它。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享同一个 memoized 的拆除流程:停止循环、等待静默与 flush 完成、分离 agent 和会话,然后回收其 scope。注册表条目分离后 ID 即可复用。由配置创建的 agent 归 loop fiber 所有;ACP 存储并 dispose 每个会话的 handle。 + +拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放先停止循环并等待 `agent.done`,再分离会话。如果使用兄弟 effect,它们会并发释放,可能在关闭 flush 之前移除 append 钩子。释放通知被隔离,不会中断拆除链。 + +### 3. Bash 所有者令牌置于 seam 中 + +后台任务的所有权归执行器持有。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` 与 `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保留在任务上,使得这道围栏在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。 + +## 验证 + +- ACP 断开连接或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。 +- 在已排队的提示词启动前取消,能阻止该提示词运行或吸收下一条提示词。 +- 重载 `dsh-tool-bash` 不会让另一个会话读取或终止已有的后台任务,因为所有权保留在执行器上。 +- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 的演示无需显式管理 handle。 + +## 会话所有者令牌在存活 agent 中唯一 + +bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务会回滚。`tool-bash` 拥有比较策略;bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。 + +## 曾考虑的替代方案 + +- **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` seam:否决。一条读取路径即可,无需冗余 API。 +- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时兄弟 effect 并发释放(`Promise.all`),store 持有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 +- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 RFC](../simplification/2026-06-20-public-agent-stop-surface.md))。 + +## 后果 + +本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。简洁的同步 `Agent.send()` 人体工学得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml new file mode 100644 index 0000000000..0e2a4891d9 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-18-session-surface.md: 31297166b735468147850a81d7fd43a8fa30a1e8 +2026-06-18-session-surface.zh.md: 9e2933a1e564b3dbb7719d55b5264f670c3b833f diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 6ba0df41c4..31297166b7 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -1,5 +1,7 @@ # RFC: Session surface — a linked list over the event log for LLM message derivation +English | [中文](2026-06-18-session-surface.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md new file mode 100644 index 0000000000..9e2933a1e5 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md @@ -0,0 +1,71 @@ +# RFC:Session surface——基于事件日志的链表,用于 LLM 消息推导 + +Status: implemented + +[English](2026-06-18-session-surface.md) | 中文 + +## 问题 + +事件日志是权威数据源,但历史操作此前没有持久化的共享机制。如果没有这样的机制,上下文压缩(context compaction)等插件只能通过顺序敏感的监听器改写派生请求,不留溯源记录,且每次新增操作都要修改 `deriveMessages()`。 + +## 决策 + +新增一个 **surface**:一条从事件日志派生、带缓存的链表,由「surface 节点」(即产出 LLM 消息的那部分事件)组成,通过事件日志中的 `surfaceOp` 标记维护。 + +### `SessionEvent` 上的两个新顶层字段 + +每个 `SessionEvent` 新增两个可选字段(与 `seq`/`time` 同属结构元数据): + +- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如:构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。 +- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。 + +### SurfaceOp:两种操作 + +```ts +export type SurfaceOp = + | 'append' // normal tail append + | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive +``` + +1. **Append**:在尾部追加一个新节点。`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message` 使用此操作。agent loop 在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时附带 `sourceEventSeqs`(例如 `assistant/message` 记录其 `assistant/chunk` 来源;`tool/result` 记录其 `tool/call` 来源)。 + +2. **Replace**:移除从 `start` 到 `end`(两端含)的节点,并在其位置插入一个新节点。`start` 和 `end` 都必须是当前 surface 上有效的 surface 节点 seq;`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍保留在日志中,但不再出现在 surface 上。 + +### SurfaceManager:基于增量,而非全量重建 + +`SurfaceManager` 类(`Session` 的私有实现)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**(上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前事件不会改变;种子日志只是在首次访问时折叠的初始增量。 + +无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。 + +`deriveMessages()` 在存在 surface 标记时使用 surface,否则回退到既有的线性扫描(向后兼容)。 + +### 持久化 + +新字段作为顶层 JSON 属性序列化。JSONL 后端无需任何修改:`JSON.stringify`/`JSON.parse` 透明地保留一切。SQLite 后端的 `events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`)。磁盘上的 `SCHEMA_VERSION` 递增以反映列集变化,并且按照预发布的 bump-and-reject 策略,由其他构建写入的数据库在打开时被拒绝,而非迁移(没有需要升级的持久化用户数据)。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`(「不稳定/预发布」立场):可选的 surface 字段被吸收而不递增版本号。 + +### 崩溃恢复 + +`repair.ts` 模块在崩溃后为孤立的工具调用合成 `tool/result` 关闭事件。这些关闭事件携带 `surfaceOp: 'append'` 和指向孤立 `tool/call` 事件的 `sourceEventSeqs`,确保重建后的 surface 有效。 + +### 不变式 + +开发模式不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq)以及 `surfaceOp`(replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。 + +每个 surface 可达事件都必须携带 `surfaceOp`,否则它会从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此要求;`append` 和种子构造函数中的运行时检查覆盖了宽化联合类型和加载的日志。无效种子在预发布格式策略下被拒绝而非升级。 + +## 曾考虑的替代方案 + +- **逐插件的 `agent/request` 包装**(surface 之前的历史操作模式):监听器排序脆弱,不留持久化的变更记录,且每次新增操作都要修改核心 `deriveMessages()`。 +- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。surface 是双向链表,端点自然以节点 seq 命名,单节点替换(`start === end`)在闭区间语义下读起来更自然。 +- **脏标记触发全量重建**而非增量处理:在会话生命周期内为 O(N²)——每次单事件追加都要重新扫描所有先前事件。 + +## 后果 + +- **`packages/core/session`**:新增 `surface.ts`(`SurfaceManager`)、新类型(`SurfaceOp`、`SurfaceIntent`)、`SessionEvent` 上的新字段、修改 `append()`(第三个必需参数 `SurfaceIntent`)、重构 `deriveMessages()`(以 surface 遍历作为唯一推导路径)、surface 感知的 `repair.ts`。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。 +- **`packages/core/agent-loop`**:所有 surface 可达的追加传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 +- **`packages/session-persistence/session-persistence-sqlite`**:`events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`);`SCHEMA_VERSION` 递增(bump-and-reject,无迁移)。 +- **`packages/support/invariants`**:surface 相关的验证规则。 +- **`packages/session-persistence/session-persistence-jsonl`**:无需修改。 +- **`packages/session-persistence/session-persistence`**:抽象接口不变。 + +Surface 是未来历史操作的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点取代该范围在 surface 上的位置,而插件自身的跟踪事件(如 `compaction/start`、`compaction/end`)则不进入 surface。回放确定性地保留这一决策。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml new file mode 100644 index 0000000000..566ce4ae59 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-18-shared-persistence-write-coordinator.md: 3fc30dc2e1382fd983d050433123f46a2cd0ed19 +2026-06-18-shared-persistence-write-coordinator.zh.md: 6c8ccef8dd5603d837dd0a9884adf9c1cd8a17d4 diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index b1773b480d..3fc30dc2e1 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -1,5 +1,7 @@ # RFC: Shared persistence write coordinator +English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md new file mode 100644 index 0000000000..6c8ccef8dd --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -0,0 +1,44 @@ +# RFC:共享持久化写入协调器 + +Status: implemented + +[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文 + +## 问题 + +`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但二者的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、串行化 flush 链、HMR(热模块替换)种子注入,以及 dispose(资源释放)排空。纯粹的种子前缀冲突与可串行化守卫已经迁入 seam 包;剩余的编排仍然是正确性密集的,并且相同的修复被应用了两次。代码级 diff 表明两个后端在**所有**这些逻辑上是逐字节一致或同算法的:四个 map(`states`/`buffers`/`chains`/`inits`)、`installWritePath`、`initFor`、`onCreated` 的四种分支、`flush`、`drain`、`serialize`、`adopt`、`adoptLivePrefix`、`assertVersion`,以及 `create`/`append`/`load` 骨架。唯一不同的只有存储原语(写字节 vs. INSERT 行)。 + +## 决策 + +将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。 + +组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险点——「协调器不得迫使非常规后端与继承层级搏斗」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 + +### 钩子接口(`PersistenceBackend<TornMarker>`) + +六个方法(五个必需 + 一个可选生命周期钩子)——协调器与存储之间唯一的 seam: + +- `name`:后端标签,用于 dispose 失败时的 `AggregateError`。 +- `loadStored(id)`:按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucket;SQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 实现创建冲突探测。 +- `loadLive(id, cwd)`:读取**限定于 `cwd`** 的已存储前缀。**刻意区别于 `loadStored`**:HMR live-adoption 只能接管与活跃会话**相同 cwd** 下的持久化日志;同 id 但不同 cwd 的日志是冲突而非恢复。合并这两个方法会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`。 +- `appendBatch(meta, events, isMaterialized)`:持久地追加一个连续批次,在尚未物化时**原子地**惰性物化会话(物化写入与第一个事件批次必须一起提交——崩溃发生在二者之间时不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 +- `commitRepair(meta, tornMarker, closers)`:使崩溃修复持久化:截断撕裂尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 +- `list()`:列出所有已存储的元数据。 +- `close?()`:可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空**之后**被 await,确保 close 失败不会掩盖排空错误。 + +### 不透明的撕裂标记 + +保持 seam 干净的唯一设计选择:崩溃修复中的「撕裂尾部在哪里」token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的标记类型:JSONL 使用要截断到的字节偏移量,SQLite 使用要从其开始删除的 seq(两者碰巧都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较**折叠在钩子内部**,因此返回的标记已经是 `number | undefined`;如果不做这个折叠,协调器就必须了解字节长度。 + +## 测试 + +共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、冲突、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个 through-coordinator 的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture 钩子),确保协调器的撕裂标记修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生撕裂标记,因此无法触达该分支。 + +## 曾考虑的替代方案 + +- **后端继承的基类**:否决,改用组合。后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 +- **更宽的钩子面**:每个候选钩子都被折叠掉了:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与第一个事件批次原子提交);没有单独的创建冲突探测(它就是 `loadStored(id) !== undefined`);`list()` 也不经过协调器透传(列举不需要任何编排)。 + +## 后果 + +协调器增加了一层间接和一个不透明的撕裂标记,但将此前每个后端重复的正确性密集编排集中到一处。其钩子面保持窄小:冲突检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,无需复制事件-缓冲区-flush 生命周期。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml new file mode 100644 index 0000000000..52a3ca3b01 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-branded-ids.md: f6d066857d8904ae5343f12310266663806a0ae2 +2026-06-20-branded-ids.zh.md: 14f82c395cdf43e2f5df5b2317dda3d45c595a64 diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 9f83e46b24..f6d066857d 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -1,5 +1,7 @@ # RFC: Branded IDs everywhere they belong +English | [中文](2026-06-20-branded-ids.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md new file mode 100644 index 0000000000..14f82c395c --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -0,0 +1,69 @@ +# RFC:在所有应当使用品牌类型的位置推行 Branded ID + +Status: implemented + +[English](2026-06-20-branded-ids.md) | 中文 + +## 问题 + +harness 已经为三个标识符打上了品牌类型:`CallId`(`packages/llm/llm/src/brand.ts`)、`SessionId`(`packages/core/session/src/types.ts`)和 `AgentId`(`packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制(由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零成本的 cast 工厂函数。`dsh-brand` 还声明了治理策略:*"品牌类型用于跨包边界且可能被混淆的 id;并非每个 string 都需要品牌类型。"* 这条策略是正确的;问题在于它只落实了一半。两个缺口使得「结构相同但语义不同」的 string 今天仍能通过类型检查。 + +**缺口 1:bash seam 中未打品牌的 ID。** `BashTask.id` 以及所有 executor/tool 边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型也通过 `task_id` 返回该值,因此混淆 task id 和 session id 既是类型正确的,也是可达的。 + +bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是拥有者 agent 的 `session.header.id`(`callerToken = (exec) => exec.agent?.session.header.id`,见 `packages/bash/tool-bash/src/index.ts`)——即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个「不匹配但类型正确」的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的「bash owner-token 别名漏洞」。 + +**缺口 2:既有品牌类型的侵蚀。** `CallId`、`SessionId` 和 `AgentId` 在注册表 map、公开查找参数、ACP 会话追踪和持久化协调器中退化为裸 string。在查找边界丢弃品牌类型,等于废掉了它的核心保护。 + +## 决策 + +纯类型变更。品牌类型是零成本 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵守既有的「并非每个 string 都需要」策略。 + +- **为 bash task id 打品牌。** 在 `packages/bash/bash/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂函数,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。品牌原语放在无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 只依赖它就能为自己的 id 打品牌——永远不需要为了获取 `Branded` 而引入 `dsh-llm`(或 `dsh-session`)。将品牌贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时一次性为计数器输出打品牌),以及 `dsh-tool-bash` 的校验/访问控制面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的 tool 边界处打品牌)。 + +- **铸造独立的 `OwnerToken` 品牌。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`)cast 为 `OwnerToken`——这是两套词汇交汇的唯一位置。bash seam 永远不导入 `dsh-session`。(理由见下一节。) + +- **阻止品牌侵蚀。** 将既有品牌传播到缺口 2 列出的 `Map` 键类型和公开方法参数:`Map<SessionId, Session>`、`get(id: SessionId)`、`Map<AgentId, Agent>`、`Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械性最大的部分,也是让*既有*品牌在查找处真正发挥作用(而非仅在结构体字段上标注)的关键。 + +示意形状(工厂模式与现有三个品牌完全一致): + +```ts ignore-check +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** A background bash task handle (generated `bash-N` by the local executor). */ +export type BashTaskId = Branded<'BashTaskId'> +export function BashTaskId(id: string): BashTaskId { + return id as BashTaskId +} + +/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */ +export type OwnerToken = Branded<'OwnerToken'> +export function OwnerToken(id: string): OwnerToken { + return id as OwnerToken +} +``` + +## 曾考虑的替代方案 + +### 为什么不把 `owner` 类型标注为 `SessionId`? + +executor 将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保持了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行从 `SessionId` 到 `OwnerToken` 的唯一转换。 + +## 不在范围内 / 可能的扩展 + +遵循「并非每个 string 都需要品牌类型」策略,刻意保持窄范围。以下每项都是合理的未来品牌候选,附有推迟理由而非承诺: + +- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表键)——一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个品牌,仅为控制本 RFC 的影响范围而暂不纳入。 +- **`ToolName`**(`ToolRegistry` 键)——由作者定义、人类可读,且很少与其他 id 混淆;候选强度最弱,可能不值得打品牌。 +- **`ErrorCode`**(`HarnessError.code`)——封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要加强类型,用 string 字面量联合类型比品牌更合适。 +- **数值序号**——轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体为它们打品牌,但它们是位置序号、很少跨边界传递,收益低。 +- **带校验的构造**——品牌工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方发放的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界对畸形输入抛异常的 `SessionId.parse()` / `isValid()` 伴生函数确实是缺口,但它是一项*运行时行为*变更,有自己的设计问题(什么算「畸形」?失败时怎么办?),应在独立 RFC 中处理,不应捆绑进这次纯类型改动。 + +## 验证 + +`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿 executor、本地实现和面向模型的 tool,且未引入 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用对应的品牌类型而非裸 `string`;来自提供方、ACP 和模型的原始输入通过品牌工厂进入,而非散落的 cast。 + +## 后果 + +- **两个面上的机械性改动。** 传播品牌类型涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误,而非静默 bug。变更可观测地是纯类型的——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);即使该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 +- **品牌类型不做校验。** 品牌类型是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是格式良好的 string,就和以前一样能通过类型检查。本 RFC 不关闭这个缺口(见「不在范围内」)——它只阻止传入错误*类别*的 id 这一类错误。 +- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 打品牌而不为 `ToolName`,为 `OwnerToken` 打品牌而不为 `ModelId`,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml new file mode 100644 index 0000000000..992793d609 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-extract-example-app-packages.md: 3a0a0f5d4b329afed72bd3c00bf880989e24fe54 +2026-06-20-extract-example-app-packages.zh.md: 9de9f79369ebad387778a0418b75dfde96b285a7 diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index 0582515c62..3a0a0f5d4b 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -1,5 +1,7 @@ # RFC: Extract example apps into packages +English | [中文](2026-06-20-extract-example-app-packages.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md new file mode 100644 index 0000000000..9de9f79369 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md @@ -0,0 +1,57 @@ +# RFC:将示例应用提取为 package + +Status: implemented + +[English](2026-06-20-extract-example-app-packages.md) | 中文 + +## 问题 + +示例目录本应是*薄*的:只包含演示的可变接线,而非演示的机制本身。在本次变更之前它是厚的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示还需要的 `logger` + `hmr`)、三个共享 YAML 片段的嵌套引入(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),以及每个示例各自的 `agent-loop`/持久化/系统提示词配置。真正的应用——每个 agent 都需要的服务主干——分散在叶子配置和那些 include 中。 + +叶子配置还拥有一个耦合的前门。ACP 要求 stdout 纯净,通过 `session/new` 创建 agent;stdio 需要控制台 logger 和一个预创建的 `main`。防止错误组合的唯一手段是行文中的警告,而三个 `start.ts` 文件重复了 Loader 引导和生命周期代码。 + +## 决策 + +每个示例现在**基本上是对一个 app package 的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**app 包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM 适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。 + +- **`@deepseek-ai/dsh-agent-spine-demo`**([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合无提供方、无执行器、无 UI 的主干,并转发 loop 的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为这个包组合的是主干而非扩展它;替换 loop 意味着提供另一个 bundle。 +- **`@deepseek-ai/dsh-stdio-demo`**([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**([packages/examples/acp-demo](../../../../packages/examples/acp-demo))各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`;ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以追加插件,但安全的组合现在是默认产物。 +- **`start.ts` 已移除。** 每个 app 包暴露一个 `bin`(`dsh-stdio-demo` / `dsh-acp-demo`);`demo:*` 脚本调用它(如 `dsh-stdio-demo ./cordis.yml`)。Loader 引导尾部、`.env` 加载和 fail-loud 守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享 app bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个薄的自执行组合,基于这些辅助函数加上自身特有的生命周期逻辑(ACP bin:快照模式选择与 stdin-dispose)。`bin.ts` 文件本身仍排除在覆盖率之外(自执行 CLI 入口,与旧的 `start.ts` 类似),由 keyless Loader 路径测试驱动。 +- **每个叶子 `cordis.yml` 精简**为后端 + 配置:LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`)、bash 执行器(`bash-local`)、stdio 演示的 `hmr`(见下方修正),以及一个 app 条目承载 app 的配置(模型、系统提示词、持久化根目录——作为 app 包自身的 `Config` 暴露,由 app 将每个值路由到其接线的目标位置:stdio 路由到预创建的 agent,acp 路由到 bridge 插件)。 +- **echo-agent 折叠到 `dsh-stdio-demo`**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。 +- **`base.yml`、`base-core.yml` 和 `acp-agent/acp-tail.yml` 退役**——它们共享的主干现在位于 `dsh-agent-spine-demo`。 + +`bash-local` 和 LLM 适配器保持为**叶子选择**:bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器可以在不触碰 app 的情况下替换进来。 + +### 实现修正:`hmr` 保留为叶子条目 + +提案将 `hmr` 列入 stdio app 内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包在两方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付: + +1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃 `loader` 服务的情况下会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,无法在进程内的单元/覆盖率测试层运行。 +2. 进程内测试层(vitest)甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。 + +关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患——在 ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧——因此将它留在叶子不会损失耦合论证所关注的安全性。**logger**(真正的耦合)保持内置:stdio app 包含它,ACP app 省略它。 + +## 曾考虑的替代方案 + +### 为什么不继续用共享 YAML include 来接线? + +旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水只能在三个 `start.ts` 文件中复制。包将「ACP app 绝不向 stdout 输出日志」从行文警告变成产物的属性:叶子中没有可以写错的 logger 条目。 + +## 验证 + +- 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。 +- `demo:echo`、`demo:repl` 和 `demo:acp` 调用 app 包的 bin。 +- 每个新包有 README 和逐文件 100% 覆盖率;每个 app 包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状失败。 +- ACP 回放 transcript 保持不变,因为插件集和加载顺序未改变。 + +## 后果 + +- **裸插件树教学法。** echo-agent 的内联 `cordis.yml` 曾一次展示所有插件;主干现在藏在 bundle 后面,因此查看完整树意味着打开 `dsh-agent-spine-demo`。app 包的 README 承担了这部分教学职责。 +- **多了一层间接。** 「这个演示加载了什么?」变成了读一个 package,而非扫一份 YAML。 + +## 相关 + +- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无提供方核心便不再有意义。 +- 建立在[能力 seam](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分之上——后端和展示层保持为叶子选择;主干是共享 bundle。 +- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放 app 特有的前门)。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml new file mode 100644 index 0000000000..9f89c454fc --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-package-hierarchy.md: faf5815222b20699a32f1af489e625ba3e891230 +2026-06-20-package-hierarchy.zh.md: 4b71cd41826e727eea19d305635c6485e18393c2 diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md index 89b50eb3df..faf5815222 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -1,5 +1,7 @@ # RFC: Reorganize packages into a modular hierarchy +English | [中文](2026-06-20-package-hierarchy.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md new file mode 100644 index 0000000000..4b71cd4182 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -0,0 +1,75 @@ +# RFC:将包(package)重组为模块化层级结构 + +[English](2026-06-20-package-hierarchy.md) | 中文 + +Status: implemented + +## 问题 + +`packages/` 原先是扁平的:18 个包全部位于 `packages/<name>/`,一个包的位置无法体现它是核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。package README 带着 `FIXME(package-hierarchy)`,`scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑,看起来都同等基础。 + +这不仅是外观问题。因为每个顶层包看起来都属于同一个公开接口面,未来移除更难;发布/lint/文档脚本不得不通过注释或手工维护的静态列表来编码意图,而非从布局直接读取。 + +## 决策 + +按模块角色分组,统一为 `packages/<group>/<pkg>/` 两层深度。分组目录是纯容器(没有 `package.json`);每个包保留其 `@deepseek-ai/dsh-<pkg>` 名称——这是仓库结构与维护策略,不是包重命名。 + +```text +packages/ + core/ (product API spine) + session/ + system-prompt/ + tools/ + agent/ + agent-loop/ + llm/ (product — capability family) + llm/ + llm-deepseek/ + llm-pi-ai/ + bash/ (product — capability family) + bash/ + bash-local/ + tool-bash/ + session-persistence/ (product — capability family) + session-persistence/ + session-persistence-jsonl/ + session-persistence-sqlite/ + ui/ (product integration) + acp/ + support/ (dev/test/example infrastructure) + invariants/ + ui-stdio/ + llm-replay/ +``` + +### 放置决策 + +- **能力族使用同名嵌套。** 一个族的接口包位于 `packages/<group>/<group>/`(`llm/llm`、`bash/bash`、`session-persistence/session-persistence`),实现和消费方作为扁平兄弟。不设额外的 `adapters/`/`impls/` 子层——每个包恰好在深度 2,workspace glob 保持简洁的 `packages/*/*`,一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包(目录名唯一,使 first-on-disk-wins 无歧义)。 +- **`session` 留在 `core/`;持久化自成一族。** 会话日志是核心产品 API。其存储后端构成一个平行的能力族(`session-persistence/`),与 `llm/` 和 `bash/` 对称,而非嵌套在 `core/session/` 下。 +- **`agent-loop` 在 `core/` 中。** 它是 `agent` seam 唯一的具体实现,但作为 harness 的默认产品循环随产品发布,因此与核心主干同住。插件仍然依赖 `agent` 的词汇,从不依赖 `agent-loop`,因此循环仍可替换。 +- **`invariants` 和 `ui-stdio` 属于 `support/`,不是产品。** `invariants` 是开发模式的契约检查。`ui-stdio` 从示例中提取以便复用和满足覆盖率门禁——它与示例耦合,因此与 `llm-replay`(快照测试回放适配器)一起放在 `support/` 中。`acp` 是 `ui/` 的唯一成员,因为它是真正的产品接口面(编辑器驱动的 ACP 桥接),在结构上不同于 readline 演示辅助工具。 + +### 去重包清单 + +包清单此前在五处重复枚举。统一的深度 2 布局使大部分可以被推导出来: + +- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选路径)映射所有包,取代逐包条目。根 `tsconfig.json` 复用该源码映射,并携带显式的 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选包含 `/*/`,朴素的正则注释剥离器会误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手工剥离注释。) +- `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导出列表,解决了 `TODO(package-inventory)`。 +- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest 生成这些引用留作后续工作(见 [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md))。 + +### 新增的护栏 + +两道 doc-sync/hygiene 门禁保证结构及其引用的正确性,使本次重组所需的人工检查不必再次手动重复: + +- `scripts/verify-package-paths.ts` 标记 Markdown 或 `.ts` 注释/字符串中的 `packages/<path>` 引用:如果该路径无法解析**且**某段命名了一个真实存在的包,则视为指向已移动包的陈旧路径。如果路径命名的包在任何地方都不存在(前瞻性提案),则不报错;因此该门禁对 proposed/implemented/rejected 统一适用。 +- `scripts/check-workspace-constraints.ts` 断言 `packages/<group>/<pkg>` 形状:分组目录不含 `package.json`,没有包扁平地位于根层级或嵌套更深。分组名称保持开放——新增分组无需修改门禁;只有深度 2 的形状是固定的。 + +## 曾考虑的替代方案 + +- **第三层(每个族下设 `adapters/`/`impls/`)**:否决。统一深度 2 使 workspace glob 保持简洁的 `packages/*/*`,一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包。 +- **将持久化嵌套在 `core/session/` 下**:否决。存储后端构成一个平行的能力族,与 `llm/` 和 `bash/` 对称,而会话日志本身属于核心产品 API。 +- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口面;`acp` 是 `ui/` 的唯一成员,因为编辑器确实在驱动它。 + +## 后果 + +本次重组在一次协调的变更中搅动了 import、workspace glob、文档链接、构建引用和包路径。这种搅动在发布前是可接受的(遵循 AGENTS.md 中「基础优先于爆炸半径」的立场),因为它阻止了扁平布局将支撑包固化为产品契约;而且这是一次性成本:通配符 `paths`、glob 推导的 publint 列表和形状门禁意味着新增一个包无需再做额外的结构编辑。 diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml new file mode 100644 index 0000000000..c69c49d2c7 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-21-mandatory-app-attribution-headers.md: 4fa773e089b3a4b682e42269a66d85aeaf5c18f6 +2026-06-21-mandatory-app-attribution-headers.zh.md: 3660b8e7de977c01a19f9ed9ac9e73409f69706a diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 102a29d613..4fa773e089 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -1,5 +1,7 @@ # RFC: Mandatory `User-Agent` attribution for provider requests +English | [中文](2026-06-21-mandatory-app-attribution-headers.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md new file mode 100644 index 0000000000..3660b8e7de --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -0,0 +1,84 @@ +# RFC:对提供方请求强制携带 `User-Agent` 归属标识 + +Status: implemented + +[English](2026-06-21-mandatory-app-attribution-headers.md) | 中文 + +## 问题 + +LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只部分做到了这一点:手写的 DeepSeek 适配器发送一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以静默地遗漏归属标识,而库封装的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 + +直接触发点来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 通过 `HTTP-Referer` 加展示名/分类头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的确切头部集合当作通用标准采纳,然后将提供方特定的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 + +## 调研 + +- **OpenRouter 的机制是提供方特定的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title` 和 `X-OpenRouter-Categories` 追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。 +- **在 agent 工具领域,`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACP(Agent Client Protocol)等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。 +- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特定附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。 +- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件的身份标识,说明它用于互操作性报告和分析,并说用户代理应当(SHOULD)在每个请求中发送它,除非被配置为不发送。这是唯一直接匹配「哪个产品在发出这个 HTTP 请求」的标准头部。 +- **`Referer` 是标准的,但 OpenRouter 的 `HTTP-Referer` 不是标准字段。** RFC 9110 第 10.1.3 节将 `Referer` 定义为获取目标 URI 的来源 URI,并用大量篇幅讨论隐私限制。OpenRouter 则要求 `HTTP-Referer`,将其用作应用 URL 标识符。该名称和含义是 OpenRouter 特有的,尽管它形似标准 `Referer` 头部的 CGI 环境变量形式。 +- **`From` 是标准的,但不适合作为强制默认。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人类的电子邮件地址。机器人代理应当(SHOULD)发送它以便服务器联系运营者,但非机器人代理不应在没有用户显式配置的情况下发送它,因为存在隐私和安全策略顾虑。harness 可以后续支持运营者联系方式,但不得凭空编造或全局强制要求。 +- **请求体中的 `user` 或 `metadata` 字段不是应用归属。** 某些模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特定的 body schema,要么不保证能通过 OpenAI 兼容网关转发。它们不能替代静态的应用身份头部。 +- **SDK 遥测头部标识的是 SDK,而非应用。** 官方和第三方 SDK 经常发送库/版本头部。这些帮助 SDK 维护者调试客户端,但除非应用显式提供产品归属层,否则它们不会将 harness 标识为应用。 +- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai` 的 `StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此库封装的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式(wire format)契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。 + +## 决策 + +在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则是:每个产品 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于库封装的适配器,库的头部钩子喂入同一个 mock 服务器断言)。 + +本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特定的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,带有自己的隐私/产品决策、测试和文档。在那之前,即使请求指向 OpenRouter,也只发送本 RFC 的共享 `User-Agent` 归属。 + +提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各个适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值: + +- `User-Agent` 的产品令牌:`deepseek-harness`(与 RFC 之前的线路值以及仓库/组织身份保持连续性) +- 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 阻塞发布,直到该仓库实际存在 + +默认值是强制的且非空。白标部署向 `attributionHeaders(identity)` 传入自己的 `AppIdentity`——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 让模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 + +线路映射(`attributionHeaders`;代码中头部名称为小写——HTTP 字段名在线路上不区分大小写): + +| 目标 | 映射 | +|---|---| +| 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | +| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档记录了等效契约,否则不发送 OpenRouter 专用头部。 | +| OpenRouter 端点 | 目前仅 `User-Agent`。本 RFC 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | +| 未来提供方 | 仅 `User-Agent`,除非后续提供方特定 RFC 接受额外头部。不以类推方式复用 `HTTP-Referer`。 | + +端点检测不属于本 RFC,因为此处不接受任何端点特定映射。如果后续落地 OpenRouter 支持,检测必须是显式的:要么是专用的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名。 + +## 验证 + +已落地的契约: + +- `dsh-llm` 为 `LlmAdapter` 作者记录了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README,以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约章节)。 +- 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。 +- `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。 +- `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。 +- 本 RFC 下没有适配器发送 OpenRouter 特定的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 +- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户稳定标识符。 +- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现行为。 + +## 曾考虑的替代方案 + +**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特定的产品功能,不是本 RFC 试图标准化的提供方无关模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在第一个共享归属辅助函数中。 + +**所有地方都发 OpenRouter 头部。** 否决。这会把一份自定义 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的字段。还有风险把 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。 + +**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/分类,不帮助 OpenRouter 等网关构建应用排名。 + +**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态产品身份,且可安全地在每个请求上发送。 + +**仅配置 opt-in 的归属。** 否决。默认关闭的设置正是适配器持续漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。 + +**以产品命名的令牌(`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` 令牌,因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 以连续性胜出:它是提供方已经从本代码库看到的身份,与组织/仓库身份和包作用域一致,且在展示文案承载产品名的同时保持线路归属稳定。 + +## 后果 + +**提供方看到流量来自 harness。** 这正是目的,但意味着此前混入通用 SDK 流量的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 + +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在创建之前该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,使其不会在未解决的情况下发版(见 `docs/development.md` 标记语义)。 + +**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;pi-ai 封装的适配器依赖 pi-ai 继续遵守 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件变红。这对抽象层是有益的压力:一个无法设置强制头部的提供方适配器无法完整实现 harness 的 LLM 契约。 + +**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 才能实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。 diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml new file mode 100644 index 0000000000..7a69d33245 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-26-file-context-as-event-gate.md: 6e78e2df5f7969b5ed9b74c0b597e2fcacbe8e82 +2026-06-26-file-context-as-event-gate.zh.md: b0806fd3e1d61a9bdaf20a728dbfcfc945013b78 diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 486d9aafb4..6e78e2df5f 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -1,5 +1,7 @@ # RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface +English | [中文](2026-06-26-file-context-as-event-gate.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md new file mode 100644 index 0000000000..b0806fd3e1 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -0,0 +1,171 @@ +# RFC:将 `dsh-fs-policy` 改为事件门禁插件,而非方法接口 + +Status: implemented + +[English](2026-06-26-file-context-as-event-gate.md) | 中文 + +## 问题 + +[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 都路由到它的方法。这使得 `fileContext` **处于调用路径上且不可省略**。工具不经过它就无法触及 `ctx.fs`,策略层拥有 fs I/O 和读取窗口化,而一个不需要观测状态策略的部署无法简单地移除该包——否则 `dsh-tool-fs` 将无法解析 `ctx.fileContext`。 + +这把三件本应可分离的事情耦合在了一起: + +1. **工具做什么**——解析路径、读取窗口、写入/编辑文件。这是工具的职责,只需要 `ctx.fs`。 +2. **新鲜度/观测策略**——"编辑前必须先读"、"写入/编辑必须基于你读到的版本"。这是 `dsh-fs-policy` 插件的职责。 +3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。 + +因为工具调用 `fileContext` 的方法,移除策略层是一个破坏性变更,而非优雅地失去一个*附加功能*。策略对于工具的运行是承重的,而非可选的收紧。 + +## 决策 + +反转控制流。**`dsh-tool-fs` 成为执行器,直接调用 `ctx.fs`**;**`dsh-fs-policy` 成为门禁 + 记录器插件**,通过事件参与,既不通过工具调用的方法,也不注册 `ctx.fileContext` 服务。 + +```text +tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs; + emits fs policy events; renders results +policy dsh-fs-policy plugin: listens to fs/write-intent + + fs/edit-intent (single-slot waterfall) and fs/observed + (emit) events; adds observed-state + freshness. +provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version + guard is OPTIONAL; owns the fs policy event vocabulary +provider dsh-fs-local local implementation of ctx.fs +``` + +该模型是叠加式的:裸 `ctx.fs` 执行原子的、无约束的文本 I/O,而 `dsh-fs-policy` 在其上叠加观测状态、读后才能编辑、以及版本守卫。因此移除策略后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了在服务边界保持策略可选,而非作为正常部署姿态。 + +`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 以及 `tools`/`systemPrompt`。 + +## 策略由提供方 CAS 强制执行,而非由 `dsh-fs-policy` stat + +`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的变更临界区检测陈旧: + +- "你读过这个文件吗?"是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。 +- "你读到的版本还是最新的吗?"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一把原子锁中。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。 + +这是刻意的设计。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,那么该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在两者之间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区内既无竞态又零额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;"必须基于最新读取"的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。 + +## 提供方契约变更:版本守卫变为可选 + +为使裸提供方不受约束,其两个变更操作上的版本守卫变为**可选**——有则守卫,无则无条件: + +```ts ignore-check +// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED. +writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome> +// undefined → unconditionally create-or-overwrite (bare default) +// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged] +// replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged] + +// editText: expected becomes optional (was the required { version: FsVersion }). +editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome> +// undefined → unconditionally replace literal text in the current content (bare default); +// a missing target still reports FS_STALE_VERSION +// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) +``` + +`FsWriteIntent` 联合类型本身不变——第三种"无条件"状态通过*省略* `expected` 来表达,因此两个变更操作共享一个对称的形状(`expected?`:省略 = 无守卫,提供 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能的"无守卫"情况是新增的,且它是裸提供方的默认行为。无论哪种情况,变更操作仍在后端的 per-target 锁内运行,因此无条件的写入/编辑仍然是原子的(不会出现文件撕裂);"无条件"去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失的目标报告为 `FS_STALE_VERSION`,为"此刻无法编辑该目标"保留一个统一的编辑失败码。 + +## 事件词汇(归属 `dsh-fs`) + +事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦契约所要求的:`dsh-tool-fs` 是事件发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。 + +这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加上一个不透明的 actor——而非面向模型的概念(行窗口、行号、渲染页脚均不会泄漏到此层)。 + +**两个 `fs/*` 决策事件是单槽位、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 即返回,因此在默认部署中它占据该槽位;一个注册更早或使用 `prepend` 的监听器会取代该策略。权限、审计和沙箱关注点仍在可组合的 `tools/execute` waterfall 上。 + +actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或窄化它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其监听器将 `object` actor 窄化为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它**不**拥有策略层的运行时 owner 结构。 + +```ts +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' + +interface Events { + /** + * Single-slot decision: produce the write expectation for the next + * ctx.fs.writeText. The default returns undefined (unconditional create-or- + * overwrite — the bare provider). The policy listener returns createIfAbsent + * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed). + * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall + */ + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined> + /** + * Single-slot decision: produce the optional version guard for the next + * ctx.fs.editText. The default returns undefined (unconditional edit of the + * current content — the bare provider; no stat). The policy listener returns + * { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or + * has not observed the target. Does NOT call next(): one decision. @mode waterfall + */ + 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + /** + * Record that an actor observed a target at a version, after a successful + * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be + * synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap + * write); the tool does not guard the emit, so a throwing listener surfaces as + * the tool's isError result. No listener ⇒ nothing recorded. + * @mode emit + */ + 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +} +``` + +`fs/*` 决策事件是**由工具分发的无绑定 waterfall**(类似 `agent/request`,由 loop 分发且无 `this`),而非服务绑定的 waterfall(如 `llm/stream`)。分发方是 `dsh-tool-fs` 插件,它不是一个服务。 + +## 工具契约(`dsh-tool-fs`) + +工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略为先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何说"后端"要求如此的措辞应改为说 fs-policy 插件要求如此。裸提供方的回退不改变 prompt 立场。 + +`dsh-tool-fs` 获得了从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为工具拥有了读取操作。这些读取渲染类型和辅助函数迁入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。 + +`dsh-tool-fs` 是一个注册全部三个工具(`read`/`write`/`edit`)的单根插件,与 `dsh-tool-bash` 对齐。它注入 `fs`(加 `tools`/`systemPrompt`),从不注入 `fileContext`。(最初的提案还将每个工具作为 `/read`/`/write`/`/edit` 子路径插件暴露,以支持聚焦部署;实现时已放弃——没有消费方需要单工具部署,且子路径发布迫使引入定制的 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理,而同级的工具包都不需要这些。每工具的注册辅助函数(`applyReadTool`/`applyWriteTool`/`applyEditTool`)保留为根插件组合的内部模块。) + +`stat` 预算通过让 waterfall 惰性产出期望值来最小化——裸默认返回 `undefined`(无守卫),从不 stat: + +- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读取后的确认 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑虚假地 `FS_STALE_VERSION`(快速失败:模型重新读取,从不基于错误版本写入,因为 `editText` 在其锁内重新检查)。 +- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。**工具内零 stat**,无论是否有 `dsh-fs-policy`。 +- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。**两种情况下工具内均零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。 + +工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,这样 `dsh-fs-policy` 就能推导其观测状态的 owner。工具不知道策略插件是否存在:它总是在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。 + +**`fs/observed` 在操作成功后触发。** 其监听器必须是同步的、不抛异常的记录器;工具不对 plain emit 做守卫,因此抛异常的监听器会在变更已成功后报告失败。异步或可失败的观测需要另一个事件契约。 + +## 策略插件契约(`dsh-fs-policy`) + +`dsh-fs-policy` 是一个插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,也不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个用于 HMR(热模块替换)的 disposer(资源释放))。它维护观测状态的 `WeakMap<owner, Map<targetKey, { version }>>` 和结构化的 owner 推导(将事件中不透明的 `object` actor 窄化为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。 + +- `fs/write-intent` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。 +- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。 +- `fs/observed` 监听器:`record(owner, key, version)`。 + +一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着"该 owner 在此版本观测过该目标",而非狭义的"已读取过"。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:变更操作将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose(资源释放)时丢弃所有状态(HMR 安全)。 + +`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外部世界。这正是从 `dsh-tool-fs` 移除方法耦合的关键。 + +## 裸提供方行为(无 `dsh-fs-policy`) + +这不是预期的部署姿态——加载 fs 工具的配置预期也会加载 `dsh-fs-policy`。这是工具不再耦合于策略方法服务后存在的无约束提供方下限。在 `dsh-fs-policy` 缺席时,每个 `fs/*` waterfall 都落入其 `undefined` 默认值,`fs/observed` 无监听器: + +- **read** 不变(它从不需要策略;只是 emit 了一个现在无人听取的 `fs/observed`)。 +- **write** 无条件 create-or-overwrite:`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无读取前置要求,无版本检查。 +- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 不带版本守卫或读取前置要求即进行匹配和重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍然适用——它们关乎字面匹配,而非新鲜度)。缺失的目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。 + +两个变更操作仍然是原子的(后端的 per-target 锁是无条件的)。简单地*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、读后才能编辑、以及版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身不变。 + +## 取代 + +本 RFC 修正——而非撤销——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。改变的是**工具与策略层之间的耦合方式**:一个强制方法服务变成了插件拥有的事件门禁,fs I/O + 读取窗口化从 `fileContext` 上移到了 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 + +## 验证 + +测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读取的 edit 均成功;有策略时,未读取的 edit 返回 `FS_NOT_OBSERVED`,未读取的 overwrite 被 `createIfAbsent` 门控。策略做出决策后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具的预算在两条路径上均为 read 一次 `stat`、write 或 edit 零次 `stat`。面向模型的 schema 逐字节不变,因此快照不变。 + +## 曾考虑的替代方案 + +- **保留 `ctx.fileContext` 作为路径内方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具不加载策略层就无法运行,使策略对基本操作是承重的,而非可选的收紧。 +- **策略侧版本检查**(`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的变更临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 +- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃。没有消费方需要单工具部署,且子路径发布迫使引入定制的 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理,而同级的工具包都不需要这些;每工具的注册辅助函数保留为根插件组合的内部模块。 + +## 后果 + +- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具对策略的方法依赖,同时保留默认策略插件;代价是多了一套事件词汇需要学习。通过将三个事件保持窄小并在每个事件上记录 default-thunk 语义来缓解。 +- **策略事件放在存储 seam 中。** `dsh-fs` 获得了两个版本决策事件加一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,因此 seam 不会沾染行窗口/观测策略类型和 agent/session owner 结构。 +- **单策略占位,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend` 的)监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能特性。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。 +- **移除读取后的确认 stat** 使后续*有守卫*的编辑在读写竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,从不是正确性漏洞;提供方锁仍然阻止基于错误版本的写入。 +- **裸提供方不做读后写入/编辑检查,也不做版本检查。** 不加载 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何现有文件。这正是保持工具独立于策略服务的刻意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;这不是发布 fs 工具的配置的预期姿态。 diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml new file mode 100644 index 0000000000..041271d286 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 72aae03361cbc088cf64f3548a43ac6253eb21eb +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 5c708cb6bfed28b2164cbd1d0b1c7368bf3e1d07 diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index de6428fb64..72aae03361 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -1,5 +1,7 @@ # RFC: stdin + extra env on the bash seam +English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md new file mode 100644 index 0000000000..5c708cb6bf --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -0,0 +1,33 @@ +# RFC:bash seam 上的 stdin 与额外 env + +Status: implemented + +[English](2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 中文 + +## 问题 + +钩子子系统运行外部钩子命令的方式与 Claude Code 和 Codex 相同:一个钩子就是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 在 `ctx.bash` 能力 seam 背后已经有一个完善的命令运行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组 kill、输出截断/溢出处理和凭证擦除。将它复用于钩子执行,意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前没有写入 stdin 或设置额外 env 的能力。本 RFC 添加这两项输入。 + +`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供这两者。环境中的凭证由 `dsh-bash-local` 的子进程环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。 + +## 决策 + +在 `BashExecRequest`(面向模型/插件的请求)和 `BashExecSpec`(`run`/`start` 实际执行的解析后规格)上**同时**添加 `stdin?: string` 与 `env?: Record<string, string>`,并在 `dsh-bash-local` 中贯穿:`resolve()` 原样传递,`run()`/`start()` 将它们传给 `runBash`,后者把字节写入子进程的 stdin 并合并额外 env。 + +三个刻意的选择: + +1. **面向模型的工具不暴露 `stdin` 和 `env`。** Shell 语法已经覆盖这些需求,重复的参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。 + +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使名称看起来像凭证。这是正确的,因为擦除的职责很窄:阻止 harness 自身 *ambient* `process.env` 中的凭证泄漏到子命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非 ambient 密钥),因此擦除不是对它的约束。`childEnv(extra?)` 的分层为 `scrub(process.env)` → `ENV_OVERRIDES`(面向模型的 `TERM=dumb` 等)→ `extra`,后者优先。 + +3. **`stdin`/`env` 在解析后规格上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主的、跨会话可读的任务——这是一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 + +`dsh-bash-local` 仅在提供了字节时才创建 stdin 管道;否则 fd 0 保持 `/dev/null`,维持原有行为。它写入字节后关闭管道。如果子进程未读取就退出导致 `EPIPE`,则忽略该错误,因为命令退出状态和输出决定结果。 + +## 曾考虑的替代方案 + +**可配置的 ambient 密钥擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的 ambient 保护。 + +## 后果 + +钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子专属变量,保留其进程组管理、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一入口。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。 diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml new file mode 100644 index 0000000000..557ac2bab4 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-event-domain-semantics.md: e05c238c52052454d3e01e82767cddd9af316a9d +2026-06-30-event-domain-semantics.zh.md: a5453824183aa3f71486b5dbd24ed8c056d9c854 diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 4cf055179c..e05c238c52 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -1,5 +1,7 @@ # RFC: Event-domain semantics — session is the fact log, agent is the live surface +English | [中文](2026-06-30-event-domain-semantics.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md new file mode 100644 index 0000000000..a545382418 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -0,0 +1,39 @@ +# RFC:事件域语义——session 是事实日志,agent 是实时表面 + +Status: implemented + +[English](2026-06-30-event-domain-semantics.md) | 中文 + +## 问题 + +harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着分类体系的增长,三个事件域之间的界限变得模糊: + +- `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。 +- `agent/*` 承载实时运行时信号,向插件传递 `Agent` 句柄。 +- `tools/*` 承载工具注册表与执行 seam。 + +两个问题促使我们明确固定这些语义。第一,若干轮次/步骤边界同时以持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)和镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)两种形式存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要一个统一、有文档的订阅表面:插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须无需阅读循环代码就能判断应该监听会话事件还是 agent 事件,以及为什么。 + +这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code 与 Codex 桥接的基础。 + +## 决策 + +**三个域,各司其职,一条边界规则。** + +- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)流:想要渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。 +- **`agent/*`——实时运行时表面。** 始终携带活的 `Agent`。两种形态:拦截型 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可修改或否决,以及瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤的**边界**不在此域——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途引导(`steering/message`)同理。 +- **`tools/*`——工具注册表与执行 seam。** + +**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中、从 `session/event` 流读取——不会被镜像为 `agent/*` emit。 + +**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处持有活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除(它们根本没有消费方);turn 镜像在 ui-stdio 迁移后随之移除——见[移除边界镜像事件 RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它拥有。移除这些 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 + +## 后果 + +- 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 的隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;acceptance 或内部校验失败仍会在边界进入日志之前逃逸。 +- 之前通过已移除 emit 观察边界的测试现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们所固定的行为(边界排序、步骤计数)不变;只是读取的流切换到了权威的那一个。那些测试「抛出异常的 turn 边界 emit 监听器」的用例被删除,因为该代码路径已不存在(没有 emit 可供抛出)。按照 [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md),行为与其测试一起迁移(或一起消亡)。 +- 循环仅在 `append('step/start')` 返回后才标记步骤为已打开(`stepOpen = true`)。内部 dispatch 校验在日志推送前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确代表已提交的边界,该边界欠一个后续的 `step/end`。 +- 本 RFC 的完整实现是[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 +- Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml new file mode 100644 index 0000000000..fc9d825dc0 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-02-fs-per-session-cwd.md: 00643955d918dff87241f4b240bb7e6774d21a0b +2026-07-02-fs-per-session-cwd.zh.md: ca37e1f41af53c43151ac82e1be1b76eaafdb97e diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md index fad851265d..00643955d9 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -1,5 +1,7 @@ # RFC: Resolve filesystem paths against the caller's session cwd +English | [中文](2026-07-02-fs-per-session-cwd.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md new file mode 100644 index 0000000000..ca37e1f41a --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md @@ -0,0 +1,34 @@ +# RFC:将文件系统路径解析基于调用方的会话 cwd + +Status: implemented + +[English](2026-07-02-fs-per-session-cwd.md) | 中文 + +## 问题 + +ACP 桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent 的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 相关工作,以及 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录运行,会话 B 中的在 B 的项目目录运行——一个服务器进程,N 个工作区。 + +文件系统路径解析使用的是插件加载时的单一 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。 + +## 决策 + +将调用方的会话 cwd 透传到路径解析中,与 `dsh-tool-bash` 对 `workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。 + +- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`。`opts.cwd` 是相对 `path` 的解析基准;绝对 `path` 忽略它;省略 `opts.cwd` 时使用后端自身的默认值。使用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。 +- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`。`config.cwd` 仍是调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。 +- `dsh-tool-fs` 的 `read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数获取会话 cwd(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 一致),并传给 `resolve`。非 agent/无 header 的调用方返回 `undefined`,后端则应用其默认值。 + +## 曾考虑的替代方案 + +### 为什么由调用方提供 cwd(而非提供方) + +提供方 seam 不应依赖 `dsh-agent`/`dsh-session`:它是一个文本存储后端,沙箱或远程实现同样满足该接口,而它们没有「agent 会话」的概念。工具已经接收到 `ToolExecution`(`exec`),其中携带了 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包边界处显式优于隐式」的约定:基目录作为显式参数到达提供方并由其执行,而非让提供方越界去读取它不应知道的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。 + +默认值只存在于**一个**地方:提供方的 `config.cwd`。`sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会制造一个提供方本来会自行选择的基目录。 + +## 后果 + +- 在 ACP 演示中,fs 工具和 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目文件夹,两类工具都在该目录下工作。 +- `FsTarget` 的标识不变:`targetKey` 仍然是解析后绝对路径的 realpath,因此 observed-state 键控和符号链接标识不受影响——正确的 per-session cwd 产生的 key 与 bash 目标一致。 +- 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。 +- 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。 diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml new file mode 100644 index 0000000000..b5dcdf42f8 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-02-result-time-applied-hunk-diffs.md: 81ab8b9827ddaec39d63e2a3f8fbb864085a9ac9 +2026-07-02-result-time-applied-hunk-diffs.zh.md: 3914bb872025d7116a047dfaf3455f527e35879a diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 218cb29297..81ab8b9827 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -1,5 +1,7 @@ # RFC: Result-time applied-hunk diffs for file mutations +English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md new file mode 100644 index 0000000000..3914bb8720 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md @@ -0,0 +1,61 @@ +# RFC:结果时刻的 applied-hunk diff 用于文件变更 + +Status: implemented + +[English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文 + +## 问题 + +[带标签的渲染意图联合类型](2026-07-02-tool-render-intent-union.md)为 `dsh-tool-fs` 的 write/edit 在调用时(CALL time)提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次 `replace_all` 如果触及五个分散位置,仍然只渲染为一对片段。 + +驱动 `claude-agent-acp` 自身的 ACP 桥接层可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原地*展示变更(而非浮动片段)的关键。我们的工具止步于调用时片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。 + +障碍在于一个 seam 边界:`presentResult(args, result)` 是 **`args` + 面向模型的 `result`(`{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放时都会运行,因此必须具有回放确定性且不能做 I/O。它看不到文件的变更前/后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数 + 版本,没有文本。因此既无法计算、也无法传递 applied hunk 给 presenter。 + +## 决策 + +新增一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来承载 applied-hunk diff。 + +### 1. 工具结果上的 `meta` 通道(core) + +`ToolDefinition.execute` 现在可以返回其面向模型的 `ContentBlock[]`(不变,常见情况)或 `{ content: ContentBlock[]; meta?: unknown }`: + +```ts ignore-check +type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } +``` + +`meta` 是工具自有的 `unknown`,core 持久化它但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷传回 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。 + +这是通用形态("工具附加持久化的结果展示"),而非 fs 专用——任何工具都可以使用。 + +### 2. 工具计算 hunk;后端返回变更前/后文本(fs) + +按照[能力-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**: + +- `dsh-fs` 扩展 `FsEditOutcome`,增加 `{ before: string; after: string }`;扩展 `FsWriteOutcome`,增加 `{ before: string | null; after: string }`(`before: null` ⇒ 新建文件,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。 +- `dsh-tool-fs` 将带上下文的 hunk 存入 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换 pending 卡片:新建或无变化的覆写回退为参数推导的全文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染错误信息。 + +### 3. 桥接层渲染 `diff` 结果卡片 + +`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`;桥接层结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中**替换**调用时的内容,因此结果 diff **取代**调用时片段(并防止面向模型的结果文本覆盖它)——两次更新的序列(先调用片段,后结果 diff)与 `claude-agent-acp` 完全一致。 + +## 曾考虑的替代方案 + +**手写或 vendor diff 算法。** 带上下文的 hunk 有已知的边界情况,因此 `dsh-tool-fs` 使用带类型的 [`diff`](https://www.npmjs.com/package/diff) 包,并在一个模块中规范化 `structuredPatch` 输出。本仓库的 vendor 策略适用于其框架源码,而非每个叶子工具。 + +## 后果 + +`tool/result` 事件现在可以携带工具私有的 `meta` 载荷——属于磁盘词汇的一部分,由 `Session.append` 在运行时限制为 JSON——任何工具都可以附加持久化的结果展示而无需再改 core。diff 卡片在会话重载和快照回放时免费复现:从日志读回,从不重新计算。代价:覆写操作在内存中同时持有变更前和新文本以计算仅用于 UI 的 hunk(`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。 + +## 非目标 + +- **实时增量 diff 流式输出。** hunk 在变更完成后一次性计算;没有逐按键 diff。 +- **对二进制/非 UTF-8 覆写做 diff。** 此类文件的 `before` 为 `null`(没有文本 diff 基础);写入仍然成功,结果渲染全文件 diff(`oldText: null`)而非带上下文的 hunk。 +- **重命名/移动 diff。** 仅对单个已解析路径做内容 diff。 +- **限制覆写 diff 基础的大小。** 覆写操作将整个旧文件读入内存以计算带上下文的 hunk(在已持有的新内容之上),因此非常大的文本覆写会为仅 UI 用途的 diff 分配两份文本。后续优化可以设定预读上限,超过阈值时回退到全文件/无上下文 diff;以 `TODO(overwrite-diff-bound)` 标记在读取位置。 + +## 相关 + +- 补齐了[带标签的渲染意图联合类型](2026-07-02-tool-render-intent-union.md)中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 +- 建立在[文件系统能力 seam](2026-06-17-filesystem-capability-seam.md)(变更前/后文本是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)(`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)之上。 +- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果等)可以附加自己的持久化结果展示而无需再改 core。 diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml new file mode 100644 index 0000000000..4c8a1f1b63 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-02-tool-render-intent-union.md: 8256f09f9c297658627d0c3d9e99ee1c5424b254 +2026-07-02-tool-render-intent-union.zh.md: ed46bf0a8bea1cbfbce4287e5dc48be21c8d8fb9 diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md index cdaf152d73..8256f09f9c 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -1,5 +1,7 @@ # RFC: Tagged render-intent union for tool-call presentation +English | [中文](2026-07-02-tool-render-intent-union.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md new file mode 100644 index 0000000000..ed46bf0a8b --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -0,0 +1,82 @@ +# RFC:用于工具调用展示的标签化 render-intent 联合类型 + +Status: implemented + +[English](2026-07-02-tool-render-intent-union.md) | 中文 + +## 问题 + +工具通过 `ToolDefinition` 上的两个回调 `presentCall`/`presentResult` 声明其调用在 UI(编辑器的工具调用卡片)中的渲染方式,返回 `ToolCallPresentation` / `ToolResultPresentation`,并带有可选的 `ToolTerminal` 子结构。这些类型在增量演进中变成了一个**可选字段的大杂烩**:调用侧有 `title`、`kind`、`rawInput`、`content`、`locations`、`terminal`;结果侧有 `title`、`content`、`terminal`;`ToolTerminal` 上有 `cwd`/`output`/`exitCode`/`signal`。职责划分含混不清: + +- 调用侧和结果侧的 `terminal` 字段重叠,bridge 需要将一个 `content` 块、一个 `terminal` 块和 `rawInput` 按调用拼接在一起,靠临时条件逻辑缝合。 +- 哪些组合是*合法的*没有文档:一个设置了 `terminal` 的调用如果同时设置了 `content`,含义是「卡片上方的描述」;一个 generic 调用如果设置了 `terminal`,毫无意义但类型允许。类型允许无意义的状态。 +- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 是 *LLM* 的 `ContentBlock[]` 词汇(text/image),工具字面上无法请求一个 diff。 + +`packages/core/tools/src/index.ts` 中现有的 `FIXME(tool-presentation)` 指明了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的标签联合类型),而不是一堆可选字段由 bridge 拼接。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方来验证词汇之后,以标签化 render-intent 联合类型的形式回归」。这个门槛现已达到:两个生产方族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot-golden 回放路径)。 + +## 决策 + +用一个**以 `card` 为标签的可辨识联合类型**替代可选字段大杂烩。工具为每次调用/结果声明一个渲染意图;bridge 按标签分发。 + +```ts ignore-check +type FileLocation = { path: string; line?: number } +type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file + +// presentCall → ToolCallView +type ToolCallView = GenericCallView | TerminalCallView | DiffCallView +interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] } +interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string } +interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] } + +// presentResult → ToolResultView +type ToolResultView = GenericResultView | TerminalResultView +interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] } +interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string } +``` + +`card` 在每个变体上都是**必填**的:一个真正的判别字段,而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何都需要新的 bridge 代码来渲染,因此一个插件添加的变体如果被 bridge 静默丢弃,比编译错误更糟。添加变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。 + +### 为什么标签联合类型优于字段大杂烩 + +- **无效状态变得不可表示。** generic 卡片不能携带终端输出;terminal 卡片不能携带 diff。旧的大杂烩允许所有这些组合。 +- **bridge 按分支分发,而非拼接。** 每种卡片一个分支,各自精确产出该卡片所需的协议格式(wire format),而非协调五个交互关系未文档化的可选字段。 +- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明 `card:'diff'`;bridge 发出 ACP `{type:'diff', path, oldText, newText}` `ToolCallContent`(已存在于 SDK 的 `ToolCallContent` 联合类型中,此前 bridge 未使用)。这是本次重设计解锁的能力。 + +### 生产方映射 + +- `dsh-tool-fs` read → `generic`(`kind:'read'`,附带一个 follow-along `location`);write → `diff`(`oldText:null`);edit → `diff`(`oldText:old_string || null`,`newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` Read/Write/Edit 分支逐字段对应。 +- `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` 和 `bash_output`/`bash_kill` → `generic`。 +- `dsh-tool-todo` → `generic`。 + +### 终端回退的归属 + +`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(bridge 在无能力路径上将 `output` 包裹为围栏代码块),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的 capability 门控行为。 + +### 纯函数性保持不变 + +`presentCall`/`presentResult` 仍然是 `args`(以及 `presentResult` 的 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件样式(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string`→`new_string`。 + +## 相对路径显示标题 + +`claude-agent-acp` 将文件卡片的标题路径相对于会话 cwd 做相对化处理(`toDisplayPath`):显示 `Read src/foo.ts` 而非 `/abs/proj/src/foo.ts`,同时保持 `locations[]`/`diff.path` **原始**(编辑器打开真实路径)。我们的 `presentCall` 是纯函数/仅依赖 args,无法看到会话 cwd,因此相对化发生在 **bridge**——bridge 已经将会话 cwd 传入工具调用渲染(与它用于解析 terminal 卡片标题的 cwd 相同)。bridge 仅对标题做相对化,通过对已知 `locations[0].path`/`diffs[0].path` 子串的精确结构化替换实现——对文件卡片类型通用,从不特判工具名。 + +## 曾考虑的替代方案 + +- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其结论明确推迟到两个真实工具和两个真实消费方存在后再做这个联合类型,而该门槛现已达到。 +- **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何都需要新的 bridge 代码来渲染,因此一个插件添加的变体如果被 bridge 静默丢弃,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟。 +- **保留可选字段大杂烩**:即「问题」一节所剖析的现状:无效状态可表示、字段交互未文档化、且完全无法请求 diff 卡片。 + +## 后果 + +新的渲染意图是 bridge switch 处的编译中断变更——这是有意为之:渲染代码必须在卡片种类存在之前就位。无效的卡片/字段组合现已不可表示,bash 回退推导归 bridge 所有,工具只返回一个结构化形状。第四种卡片(表格、图表)的门槛是在同一个变更中编写其 bridge 分支。 + +## 非目标 + +- **实时增量 `terminal_output_delta` 流式输出**与**命令分类**:终端渲染 RFC 自身推迟的后续工作,本 RFC 不涉及。 + +## 相关 + +- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做标签化 render-intent 联合类型」)中的推迟决定。该门槛现已达到;本 RFC 即是那个联合类型。 +- 由 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:该 RFC 增加了一个持久化的 `meta` 通道,使 write/edit 在结果时发出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 站点一个,或新建文件的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 +- 将 `ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view(`_meta` terminal 卡片约定和 capability 门控不变;仅 harness 侧的展示类型改变)。 +- ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。 diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml new file mode 100644 index 0000000000..25679116f6 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-03-filesystem-directory-listing-seam.md: bb8d9c4deda18b320b85d548bbd5bcb32f1c1d72 +2026-07-03-filesystem-directory-listing-seam.zh.md: a0332fe6cec576ad1c5b4722e2decb87344aeee1 diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md index 451cce45d3..bb8d9c4ded 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md @@ -1,5 +1,7 @@ # RFC: Add direct directory listing to the filesystem seam +English | [中文](2026-07-03-filesystem-directory-listing-seam.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md new file mode 100644 index 0000000000..a0332fe6ce --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md @@ -0,0 +1,53 @@ +# RFC:为文件系统 seam 添加直接目录列举能力 + +Status: implemented + +[English](2026-07-03-filesystem-directory-listing-seam.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-fs` 是文件系统访问的提供方 seam,本地后端与未来的非本地后端共享同一个 `ctx.fs` 契约。在本次变更之前,它能解析路径、stat 目标、读取文本、流式读取文本、写入文本和编辑文本。这对面向模型的文件工具已经够用,但对于需要枚举目录而又不想直接导入 `node:fs` 的非模型侧消费方来说还不够。 + +直接的压力来自 skill 加载:读取单个 `SKILL.md` 已经可以走 `ctx.get('fs')`,但发现哪些 skill 根目录下包含 `<name>/SKILL.md` 或 `<name>.md` 仍需要目录枚举。如果只在 `dsh-skill` 中添加目录列举,要么保留一个直接的 Node 依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。 + +本决策只添加提供方能力,不引入面向模型的 `ls`/`list` 工具,也不改变 skill 发现逻辑。那些消费方需要独立的 UX、提示词和策略决策。 + +## 决策 + +在 `@deepseek-ai/dsh-fs` 中添加 `FileSystem.listDir(target, signal?)`。 + +`listDir` 仅列举一级目录。它以稳定的名称顺序返回直接子项,包含: + +- `name`:子项的 basename。 +- `type`:`file`、`directory` 或 `other`。 +- `target`:已解析的子项 `FsTarget`。 +- `version`:可用时提供的轻量元数据。 +- `size`:可用时提供的常规文件大小。 + +它从不读取文件内容。递归遍历、glob 匹配、分页、搜索、文件监听和面向模型的渲染均有意不在范围内。 + +本地后端通过 `readdir({ withFileTypes: true })`、`resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的提示词/列表输出稳定,并提升前缀缓存复用率。 + +损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。列举目录或解析/探测子项元数据时遇到的权限或后端 I/O 故障会以结构化的 `FsError` 代码使整个列举失败: + +- `FS_NOT_FOUND`:目标不存在。 +- `FS_NOT_DIRECTORY`:目标存在但不是目录。 +- `FS_PERMISSION_DENIED`:权限不足。 +- `FS_IO_ERROR`:其他后端 I/O 故障。 +- `FS_ABORTED`:调用被中止。 + +## 曾考虑的替代方案 + +**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其提示词、schema 和渲染契约与提供方原语无关。 + +**让每个消费方自行枚举目录。** 否决。这会把 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。 + +**让 `listDir` 支持递归或 glob 形式。** 暂时否决。skill 根目录发现只需要直接子项,简单的单级列举是未来消费方可以安全组合的最小后端契约。 + +**跳过元数据解析失败的子项。** 否决。API 承诺返回已解析的子项 target,因此解析子项时遇到的权限/IO 故障属于契约失败。损坏或已消失的子项是例外,因为它们仍可在不声称拥有一个活跃已解析文件的前提下被表示。 + +## 后果 + +每个文件系统后端现在必须多实现一个提供方原语。这是 harness 尚未发布时有意为之的基础工作,但也意味着未来的沙箱/远程后端需要定义等价的直接子项列举行为。 + +该能力仍然面向提供方。在消费方落地之前,ACP/模型会话仍需使用 `bash` 等既有工具来列举目录。没有面向模型的 `listdir` 工具是预期行为,而非接线遗漏。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml new file mode 100644 index 0000000000..8ae2221209 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: fce9d555c8843b99fdbfa7b652b46d0b88053935 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 5af4fca19649d4ee458eaa6a23ae7374abdd89e4 diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 854807c109..fce9d555c8 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -1,5 +1,7 @@ # RFC: Prompt variables and tool-guidance ownership +English | [中文](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md new file mode 100644 index 0000000000..5af4fca196 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -0,0 +1,72 @@ +# RFC:提示词变量与工具指导归属 + +Status: implemented + +[English](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 中文 + +## 问题 + +组装后的系统提示词有四个缺陷,同属一类:harness 已经掌握的事实在别处被手工重述,然后漂移。 + +**模型无法知道自己的名字。** `AgentOptions.model` 驱动每次请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,且 `assemble()` 根本不接受任何 per-agent 输入。 + +**工具指导是叶子 YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml` 和 `examples/acp-agent/cordis.yml` 的 `systemPrompt` 字符串中——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则以 `ctx.systemPrompt.section()` 贡献的方式持有各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种割裂的症状道歉,stdio 的欢迎横幅也手动枚举了工具集。 + +**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「使用 read 工具……」再读到「你是 coding-agent」——与身份优先的惯例(Claude Code、Codex)相反,且在 section 流水线之外形成了第二条组合路径。 + +**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义撰写的描述——"a separate agent that works in its own context … it does not see this conversation"——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题同族:`PromptSection.name` 文档写着"(diagnostics / dedup)",但重复项被静默接受。 + +## 决策 + +**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包的 prompt section。harness 出处 → 静态的 `harness:identity` section。部署角色和行为 → 部署的 persona。 + +### 组装上下文 + +`SystemPrompt.assemble(context)` 接受一个可 merge 扩展的 `AssembleContext`。`dsh-system-prompt` 声明用于 scoped routing 的可选 `scope` 选择器,而 `dsh-agent` 通过 declaration-merge 将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖环)。循环在每一步调用 `assembleContextFor(agent)`,使两个字段标识同一个 agent;section 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall(瀑布式事件)也会收到它,监听方可据此按 agent 过滤或扩展。 + +### 提示词变量 + +插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装时将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝:未知的 own-property 引用、注册的 provider 返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被再次扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 + +`dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下文的 section):它们是本循环所驱动的 agent 的运行时事实,替换循环自行提供自己的变量。 + +### Persona 作为 order-0 section + +`dsh-system-prompt` 持有 order 为 `-100` 的 `harness:identity` 和 order 为 `0` 的已配置 `deployment:persona`,因此两者在替换循环时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,`agent/pre-step` 因此能测量用于压缩(compaction)的确切提示词。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 分段为:identity `-100`、persona `0`、工具指导 `100–199`。 + +### 工具指导归属 + +每个工具的语义和选择指导存放在工具描述中。Prompt section 仅承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的描述已包含完整契约。部署 persona 只包含角色和行为。 + +### Subagent 对话历史描述符 + +`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和 prompt 参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一事实。提供方生命周期事件使该措辞与响应式的 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。 + +## 曾考虑的替代方案 + +- **循环自行组合一行身份文本**——在必须保持精简的那个包里硬编码面向模型的行文("plugins, not loop changes"),且在 section 流水线之外形成第二条组合路径。(身份确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署方需要移除它时的逃生阀。) +- **通过 `agent/request` waterfall 注入模型名称**——提示词文本在两处组合,且 `agent/pre-step` 的 `fullSystemPrompt` 会遗漏它,导致压缩(compaction)测量的提示词与模型实际看到的不一致。 +- **在每个 persona 中手写模型名称**——与上方一行的 `model:` 键重复,配置修改后默默失实——正是本 RFC 要治的病。 +- **宽松插值(未知引用保留原样或替换为空)**——一个拼写错误 `{{modle}}`(或一个空洞)会被送到模型,直到 transcript(文本记录)审查才有人注意到。 +- **在配置中逐实例手写 subagent 措辞**——面向模型的行文重新回到每个部署 × 每个实例,又是同一个病。**按 provider 名称匹配措辞**——`providerName` 本身是配置,重命名 provider 后会静默拿到错误的措辞。 +- **在 `apply` 时解析 provider(加载顺序要求)** 和 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**——provider 生命周期事件的替代方案;均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。 + +## 不在范围内 + +- 更多变量(`date`、平台、git 状态)——注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。 +- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化)——推迟到 session-cwd 方案重新讨论时。 + +## 交付的不变式 + +- coding-agent 提示词通过一条组装路径渲染:identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 +- fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。 +- 未知、无值、格式错误或不平衡的变量引用会指名 section 并抛出异常;重复的 section、变量和工具注册也会抛出异常。 +- 快照回放与提示词无关:它按轮次和步骤索引已录制的 chunk 流,不比较发出的请求。 + +## 后果 + +- 组装后的提示词中每个事实现在恰好有一个归属方,叶子 YAML 中手写的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 +- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词中的声明在该步骤就会过时;如果一个插件在那里**提供**模型(options.model 未设置——循环文档记载的回退路径),变量在渲染时无值,含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,且正是归属规则本身:拥有该延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 +- 当一个已绑定的 provider 不在位(尚未激活、已卸载、HMR(热模块替换)重载中)时,subagent 工具不存在,该窗口内的模型请求只是缺少它。这是诚实的状态——替代方案是一个描述或执行都不可信的已注册工具。 +- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——而且这是一个我们希望大声暴露的撰写错误。 +- 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,届时再添加。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml new file mode 100644 index 0000000000..7357dfe0f8 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-05-reconstructable-requests.md: 0978cd8760c6a0420be1bf0a3baf6b50c1a04a13 +2026-07-05-reconstructable-requests.zh.md: 82f9cf085db3d6cd408e54a8e7cf99082d848176 diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index d95a709ada..0978cd8760 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -1,5 +1,7 @@ # RFC: Every LLM request is reconstructable from the session log +English | [中文](2026-07-05-reconstructable-requests.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md new file mode 100644 index 0000000000..82f9cf085d --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -0,0 +1,55 @@ +# RFC:每个 LLM 请求都可从会话日志重建 + +Status: implemented + +[English](2026-07-05-reconstructable-requests.md) | 中文 + +## 问题 + +请求流水线此前不保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。 + +快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只追加、从不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型必须看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。 + +## 决策 + +### 原则 + +**模型可见 ⟺ 已记录。** 凡到达模型请求的内容,都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何持有日志的人都能逐字节重建它。精确的范围说明:保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由它推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对已记录区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。 + +前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。逐字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3。 + +### 机制 + +**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次;surface 改写(压缩的 `replace`——`SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,其中的消息是共享的、深度冻结的:通过投影修改已记录的历史是不可表达的(会抛异常),取代了旧的每次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 + +`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 写入完整的初始、恢复或回退快照。`request/header-delta` 通过公共前缀/后缀行裁剪编码系统提示词变更,通过按名称键控的增/删/改编码工具变更,通过完整替换编码配置或前缀变更。`foldRequestHeader`、`diffHeader` 和 `applyHeaderDelta` 是纯编解码器。每个循环实例在其首次请求时写入一个快照,以锚定进程边界。Delta 仅是优化:写入方验证往返等价性,对不可表达的变更(如纯工具重排序)回退到完整快照。 + +每一步重建 prompt 组装。实例的第一步中,`agent/session-prefix` 用仅限请求的开场消息扩展一个冻结的空种子;结果被冻结并缓存于该循环实例。`agent/pre-step` 随后在消息快照紧接 `step/start` 之前接收组合后的前缀。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,而模型可见的内容通过已记录的通道进入。循环记录欠写的 header 事件(前缀唯一的持久化归属),从前缀、快照和 header 构建 `GenerateOptions`,并深度冻结它,同时保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和其锚定快照是否已写入。 + +**`step/start` 是重建边界。** 一步从该序列之前的事件派生消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step` 是当前请求所需内容的 seam。Header 重建折叠该步骤自身的 `request/header*` 事件,或在无新 header 写入时沿用前一次折叠结果。 + +**强制执行。** 在开发环境中,`dsh-invariants` 通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环请求通过其冻结形态和 session id 识别;直接的一次性调用被排除。正确性依赖于序列有界的重建而非监听器顺序。带密钥的 e2e 要求首次请求之后出现正数的 cache-read token;逐步 usage 是生产信号,header 变更或压缩表现为下一步 cache-read 的下降。 + +### MiniCode 形态:采纳,但溯源箭头反转 + +与 MiniCode 一样,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同的是,事件日志仍是真源,因为它还拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志派生的消息和 header 折叠结果,使每个请求都可独立检查。 + +## 曾考虑的替代方案 + +- **客户端作为真源**(照搬 MiniCode):在日志之外出现第二个生效的真相——两者漂移而无人察觉;见上节。 +- **镜像日志的有状态传输客户端**:重复对话状态,需要围绕监听器做回滚,留下未记录的编辑面,且仍无法重建请求 header。Session 拥有的缓存加已记录的 header 避免了这些分裂的真相。 +- **逐次调用的请求标量**(每次 `agent/request` 分发时传入一个可自由修改的配置):监听器可以零记账地逐次切换 model,悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态;waterfall(瀑布式事件)提议,日志记录。 +- **检测并报告**(比较连续请求,发现分歧时警告):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。 +- **事件驱动组装**(仅在变更信号时重新渲染):存在信号遗漏的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步渲染加值比较在零信号纪律下仍然健壮。 +- **Header 事件上的叙事字段**(delta 上的 `reason`/`changed` 列表):可通过 diff 连续事件派生——每个事实只有一个归属;快照携带 reason 是因为锚点的成因无法从数据本身派生。 + +## 后果 + +- 一个无法由日志解释的请求不可能被意外构造——无论是循环还是监听器;修改已构建的请求会抛异常;每次 header 变更都是一个持久的、可 diff 的日志事件。 +- 在建议通道之间做选择是变更频率决策,而本设计让稳定的那个成为结构性的:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此它以零边际成本扩展可缓存前缀,且**不可能**在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每个都是持久的 `context/message`,付出一次代价后即享受前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了所有当前更新模式)。 +- 在提供方处仍需全价的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一个不同)。提供方自身的 reasoning-content 排除由服务端管理。 +- `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。 +- 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属于压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 +- 会话日志每个对话增长一个 `request/header` 快照(系统提示词 + 工具 schema:主导项),加上真正变更时的 delta——相对于 `assistant/chunk` 的体量很小;`SESSION_FORMAT_VERSION` 保持 `0`(预发布期间的变动被吸收,后端拒绝而非迁移)。 +- 快照 golden 文件变更一次(每份 transcript 增加其 header 事件);写文件系统的 fixture 以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只往返 cwd 无关的参数路径。 +- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特有的额外项(reasoning 选项、额外 body 参数)应归属何处。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml new file mode 100644 index 0000000000..1bf06ce567 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-05-subagent-provider-lifecycle-events.md: 6d711f2a6d8496a8a229ec63d86dd89816efb6f8 +2026-07-05-subagent-provider-lifecycle-events.zh.md: 45eedcfdfa834722000c9f2c15e3955ced791c2f diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 30674f8c8b..6d711f2a6d 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -1,5 +1,7 @@ # RFC: Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed` +English | [中文](2026-07-05-subagent-provider-lifecycle-events.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md new file mode 100644 index 0000000000..45eedcfdfa --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -0,0 +1,36 @@ +# RFC:Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed` + +Status: implemented + +[English](2026-07-05-subagent-provider-lifecycle-events.md) | 中文 + +## 问题 + +[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 使 `dsh-tool-subagent` 从其提供方**派生**面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),从而让 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在**工具注册时**就已固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 + +如果在工具插件的 `apply` 时刻解析提供方,就会产生隐式的加载顺序要求("在 cordis.yml 中把后端列在工具前面")。这一要求行不通,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不等待激活完成:一个延迟到达的后端可能导致工具 fiber 失败,即使它在配置中列在前面也是如此。Loader 不提供同级顺序保证——"异步状态不是同步状态"(见[防御性模式](../../../defensive-patterns.md))。 + +## 决策 + +注册表将提供方的成员变动作为类型化事件广播,消费方镜像这些事件而非假设顺序: + +- **`subagent/provider-added(provider)`**:一个提供方在 `ctx.subagents` 注册表中变为可解析。在注册时发出。 +- **`subagent/provider-removed(name)`**:一个提供方离开了注册表(其插件 fiber 被 dispose——卸载或 HMR 重载)。从注册的 disposer 中发出。 + +`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞;当提供方离开时注销工具;在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不可能对模型撒谎。这里**刻意不留**任何需要文档化的加载顺序要求:事件使顺序问题消失,而非将其钉死。 + +这些事件还补全了该 seam 的词汇:`ctx.subagents` 是一个命名注册表,多个委派后端(`spawn`、`fork`、`acp`)在其上共存;一个内容会被其他插件用来派生状态的注册表,应当以类型化事件广播成员变动,而非要求轮询或依赖加载顺序。 + +## 曾考虑的替代方案 + +- **在 `apply` 时解析提供方,不存在则抛异常**:否决。"先列后端"会声称一个 Loader 并不提供的顺序保证。 +- **重试查找(轮询直到提供方出现)**:最终会收敛,但在框架已有的机制(effect 注册 + disposal)之外自行发明了一套私有就绪协议;而且它无法感知提供方**离开**,因此 HMR 会让一个措辞描述着已 dispose 后端的工具滞留。 +- **仅在 section 中放置 subagent 措辞,在组装时延迟解析**:同样能容忍任意加载顺序,但把 tool-choice 引导移出了描述,与 prompt-variables RFC 确立的归属规则相矛盾(每个工具的语义和使用时机属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 +- **根据提供方名称而非提供方对象来确定措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。 + +## 后果 + +- 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录,不会饿死后续镜像或扰乱拆卸流程。`start()` 仍然在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../cordis-catalog/events.md)和[生产者/消费者映射](../../../event-producer-consumer.md)。 +- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处派发的工具——工具注册表的 `tools/change` 事件确保 prompt 组装保持最新。 +- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,且被延迟捕获。** 如果两个 `dsh-tool-subagent` 实例命名了不同的提供方但相同的 `toolName`,二者都会等待,先到达的提供方触发注册;第二个注册仅在**其**提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一爆炸半径;工具注册表的重名拒绝机制仍是最终兜底。 diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml new file mode 100644 index 0000000000..cc697c3e30 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-timeout-deadline-library.md: 9906aa7cce40ffd7b5f7082199d05edb8d0a54b2 +2026-07-06-timeout-deadline-library.zh.md: ef99a1a051f3cedbe5a2770e5bbeeebe716745c4 diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md index 7aa987c60f..9906aa7cce 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -1,5 +1,7 @@ # RFC: A shared timeout/deadline primitive, with hard-kill left to each capability +English | [中文](2026-07-06-timeout-deadline-library.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md new file mode 100644 index 0000000000..ef99a1a051 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -0,0 +1,98 @@ +# RFC:共享的超时/截止时间原语,hard-kill 留给各能力自行实现 + +[English](2026-07-06-timeout-deadline-library.md) | 中文 + +Status: implemented + +## 问题 + +超时处理在各个承载工具的能力之间逐渐分化,而这种分化并非表面的——同一套逻辑被三种方式各自重新实现,每种都带着自己微妙的正确性负担。 + +- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器——用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器——各自调用同一个 `kill()` 闭包,该闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)各自独立锁存。 +- **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手工搭建*的超时:它构造一个 `AbortController`,接入 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因——因为 reader 只抛出裸 `AbortError`。 +- **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本 RFC 中保持无超时——见「后果」。) + +每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「被取消」——而融合和原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 舞步就是证据)。与此同时,各能力执行的*终止*动作不可归约地不同:bash 杀的是 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止的是进程内的 `fetch`(undici 拆掉 socket)。不存在一种单一机制能停止所有这些工作。 + +## 决策 + +`@deepseek-ai/dsh-timeout` 位于 `packages/util/`(与 `dsh-brand` 同级),拥有超时的*计时与分类*这一半;*终止*那一半——hard kill——留在各能力的实现中。它是一个纯函数库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何东西、不持有跨调用状态、不发射事件。刻意不设中央「超时服务」——那样的服务必须知道如何停止每个能力的工作,而这正是微内核要排除在共享层之外的知识,也是 Codex 的 `ExecExpiration` 作用域仅限于 exec 家族所示范的。 + +### 库的对外接口 + +三个函数加一个 reason 类型: + +```ts ignore-check +/** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */ +export class TimeoutReason extends Error { + override name = 'TimeoutReason' + + constructor(readonly code: string, readonly timeoutMs: number) { + super(`${code} after ${timeoutMs}ms`) + } +} + +/** Validate/fill a caller's optional positive hint from the backend's default, then cap at its max. */ +export function clampTimeout( + requested: number | undefined, + def: number, + max: number, + name = 'timeoutMs', +): number + +/** + * Build a deadline signal that aborts on upstream cancellation OR on timeout, + * with the timeout carrying a `TimeoutReason`. `timeoutMs <= 0` means "no + * timeout" (background tasks): forward only the upstream signal, arm no timer. + * The returned object's `[Symbol.dispose]` clears the timer — `using` for a + * scope-lifetime consumer, a manual call for an event-lifetime one. + */ +export function deadline( + upstream: AbortSignal | undefined, + timeoutMs: number, + code: string, +): { signal: AbortSignal; [Symbol.dispose](): void } + +/** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */ +export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined +``` + +`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的无超时哨兵,用于后端自有的后台任务;外部提示经 `clampTimeout` 后必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,但具有相同的 disposal 形状。提供方将超时原因翻译为 seam 特定的结果。`timeoutOf(signal, code)` 通过 code 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力的超时。 + +### 分工 + +| 关注点 | 负责方 | +|---|---| +| 校验请求提示并钳位 default/max | `dsh-timeout`(`clampTimeout`)——纯算术加共享的正有限请求契约 | +| 启动定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) | +| 清除定时器 | `dsh-timeout`(`[Symbol.dispose]`) | +| 中止后分类首个 abort reason | `dsh-timeout`(`timeoutOf`) | +| **实际终止工作** | 各能力的实现 | +| default/max *值* | 各能力的配置 | +| 超时 `code` 字符串 | 各能力(`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT`) | + +信号只*通知*;终止始终是监听者的职责,而监听者因能力而异。bash 自己写 `addEventListener('abort', kill)`,因为 OS 进程活在本运行时之外,没有别的东西会杀它;web 把 `d.signal` 交给 `fetch`,undici 拆掉 socket。这也是文件 read/write/edit 不接受 **`timeoutMs`** 的原因:本地系统调用至多只能尽力中止,超时无法强制 `fsync`/`rename` 停下,加一个超时等于引入一个违反「显式优于隐式」的隐式默认值。两个参考 agent 出于同样的理由都不给文件 I/O 设超时。 + +### 各能力如何消费 + +- **web_fetch**——工具层保持校验并转发;提供方手工搭建的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被提供方自有的 `deadline`/`timeoutOf` 取代。上游信号已预先中止时仍立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 +- **bash**——`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 + +## 后果 + +- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,seam 类型 `BashRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 +- `SpawnSpec.timeoutMs` 与 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残留保留:`runBash` 不再拥有定时器、执行器拥有分类逻辑后,它们无处被读取。这是与字面提案形状(向 `runBash` 传 `timeoutMs: 0`)的唯一偏差;在逐文件覆盖率门禁下,一个始终为 0 且无人读取的字段是死代码。 +- web_fetch 去掉了自建的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 +- `AbortSignal.any` 与 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 + +不在本 RFC 范围内,列出以标明边界:`web_search` 可以在其 tool-schema/快照覆盖率规划完成后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,hard kill 仍是各能力自己的事。 + +## 曾考虑的替代方案 + +**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核理由否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查)——这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 作用域仅限于 exec 家族,正是因为它驱动的 kill(`killpg`)是进程家族特有的;MCP 和 model-stream 各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 + +**每个工具各自实现超时,不共享代码(之前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手工搭建的 controller/reason 逻辑正是未来每个网络/进程工具都要重新推导的,而融合 + `signal.reason` 恢复是容易出错的部分。Claude Code 容忍完全重复;本仓库有一条统一的共享中止通道(每次 `execute` 上的 `exec.signal`),使一个小型共享原语严格更干净,因此成本/收益不同。 + +**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在 deadline 时 resolve *工具调用*的 promise,而不停止底层工作——子进程或 fetch socket 会泄漏。发出信号并要求能力去监听,才能强制一条真正的终止路径存在。这与「dispose 必须达到静止态,而非仅仅请求它」的防御性规则一致。 + +**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了自建定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径不变。 diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml new file mode 100644 index 0000000000..36a4652f26 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-07-tool-call-timeout-policy.md: 0e69c8504dd34dfc4427bf1f3865a80be93eff9d +2026-07-07-tool-call-timeout-policy.zh.md: d842d0a3c811e7502f4d45baba49010a06c5f1a5 diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 7bd4e2462d..0e69c8504d 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -1,5 +1,7 @@ # RFC: Tool-call timeout policy as a plugin +English | [中文](2026-07-07-tool-call-timeout-policy.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md new file mode 100644 index 0000000000..d842d0a3c8 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -0,0 +1,111 @@ +# RFC:工具调用超时策略作为插件 + +Status: implemented + +[English](2026-07-07-tool-call-timeout-policy.md) | 中文 + +## 问题 + +[超时/截止时间 RFC](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵守 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写形态:工具作者通常只需将 `exec.signal` 转发给所调用的实现,而部署策略来决定预算。 + +与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 来执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一后端复用了前台执行、后台启动、后台轮询和钩子调用。一步到位地把所有超时都移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 + +## 决策 + +工具调用超时是一项仅适用于面向模型的工具执行的策略,由三部分组成: + +- `@deepseek-ai/dsh-timeout` 仍然是拥有 `deadline()` 和 `timeoutOf()` 的共享库。 +- `@deepseek-ai/dsh-tools` 在 `tools/pre-execute` 和 `tools/post-execute` 之间有一个环绕分发的 waterfall(瀑布式事件)`tools/execute`。 +- `@deepseek-ai/dsh-timeout-policy` 从注册表读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 + +执行流水线为: + +```text +ctx.tools.execute(exec) + -> tools/pre-execute + -> tools/execute + -> registry dispatch (the base next()) + -> tool.execute(args, exec) + -> thrown tool errors normalize to ToolExecutionResult + -> tools/post-execute +``` + +默认行为是保守的:未声明 `timeoutMs` 的工具不会从该插件收到 `TOOL_TIMEOUT` 截止时间。 + +### `tools/execute` 环绕 seam + +`@deepseek-ai/dsh-tools` 声明了一个 `tools/execute` waterfall,其基础 `next()` 是「分发并规范化」的 thunk:即同一个内部 `try`/`catch`,它将抛出的工具错误(或未知工具错误)转换为 `isError` 的 `ToolExecutionResult`。监听器接收 `(exec, next)`:调用 `next()` 委托给分发(返回其结果,可选地包装),或返回替代结果以短路分发。整条流水线仍处于 `execute` 的外层 try/catch 之内,因此抛出异常的监听器会变成 `isError` 结果,永远不会导致轮次失败。 + +catch 是基础 `next()` 而非 waterfall 之外的东西,这一点是关键:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为正常的错误结果,然后 `timeout-policy` 才能将最终结果替换为 `TOOL_TIMEOUT`。 + +### `timeout-policy` 插件 + +该插件是 `@deepseek-ai/dsh-timeout-policy`,位于 `packages/timeout/` 分组中,是一个零配置的函数/命名空间插件(`name` / `inject` / `apply`)。每个工具的预算声明在工具自身上,而非此插件上:`ToolDefinition` 携带可选的 `timeoutMs`,由拥有该工具的插件从自身配置中设置。例如 `dsh-tool-web` 将 `fetchTimeoutMs` / `searchTimeoutMs`(默认 30000)解析到 `web_fetch` / `web_search` 的定义上: + +```yaml +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetchTimeoutMs: 30000 + searchTimeoutMs: 30000 +``` + +超时声明在工具定义上而非自由文本的名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 会校验预算为正有限数。分发期间,执行器派生截止时间信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 + +信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的参数,使用共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此 Cordis 的文档惯用法——修改共享对象再委托——是唯一能到达分发的机制。插件在 `finally` 中将 `exec.signal` 恢复为调用方的原始信号,使 `tools/post-execute` 永远不会看到此插件的(可能已中止的)截止时间信号。 + +`timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止时间代码(作用域化,使嵌套的外层截止时间读取为普通取消),以及结构化工具结果的错误代码。其替换结果为: + +```ts ignore-check +function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { + return { + content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], + isError: true, + error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + } +} +``` + +这是一个协作式截止时间。它不会通过与工具 promise 竞速来杀死任意工作;工具或其调用的能力必须遵守 `exec.signal` 并达到静止状态。因此声明 `timeoutMs` 的含义是「此工具对 `exec.signal` 是协作式的」,插件 README 将此作为契约声明。 + +可重建性不需要新的会话事件:`TOOL_TIMEOUT` 就是该调用最终面向模型的 `tool/result`,因此现有会话日志已经记录了下一次模型请求所看到的内容和结构化 `{ name, code }` 错误。 + +### 现有工具适配 + +`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent 的形态,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。 + +`dsh-web-fetch-local` 保留一个配置的提供方级 `timeoutMs`,作为直接调用 `ctx.web.fetch()` 的调用方和配置错误部署的大资源兜底;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常获胜。 + +`bash` 保持当前的后端超时路径。`dsh-tool-bash` 继续暴露 `timeoutMs` 和 `run_in_background`;`dsh-bash-local` 继续使用 `@deepseek-ai/dsh-timeout` 处理 `BASH_TIMEOUT`;钩子桥接继续调用 `runHook()` 并通过 `ctx.bash` 传递 `timeoutMs`。这保持了前台/后台/钩子行为的稳定。 + +`read`、`write`、`edit`、`todo_write`、`bash_output` 和 `bash_kill` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们要么只能尽力而为,要么没有必要。 + +未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现,无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对此类工具造成问题,bash seam 可以后续添加调用方拥有截止时间的模式;那不在本次范围内。 + +## 曾考虑的替代方案 + +**将插件命名为 `tool-timeout`。** 字面的 RFC 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该守卫要求每个匹配项注册一个面向模型的工具。此插件不注册任何工具——它是 `tools/execute` 的包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制一个误导性的启动条目。包名为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 分组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 + +**仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的原有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。对 web 类工具而言它不够好,因为每个新的支持超时的工具都必须自行选择校验、上限语义、文档、快照和分类。插件集中了策略和分类,同时让每个工具的 schema 专注于业务输入。 + +**立即将所有超时策略移出 bash-local。** 长期更干净:bash-local 将变为纯子进程执行器,所有调用方拥有自己的截止时间。作为第一步它不合适,因为钩子直接调用 `ctx.bash`,而 bash 模型工具有前台/后台语义,这与工具调用的生命周期不同。保留 `BASH_TIMEOUT` 维持了这些路径的稳定,同时工具调用超时在更简单的工具上验证自身。 + +**为所有工具使用全局默认预算。** 方便,但会让工具作者意外:任何偶然运行超过全局预算的工具在插件加载后就会开始失败。逐工具声明的预算使采纳成为有意识的行为。 + +**暴露面向模型的 `timeout_ms` 覆盖参数。** Claude Code 的 `WebFetch`/`WebSearch` 和 Codex 的 web 工具将超时排除在模型调用形态之外。模型覆盖会使超时成为提示词语义的一部分,并迫使 `timeout-policy` 引入 schema/参数剥离规则。Web 超时仅作为部署策略。 + +**让 `timeout-policy` 自行匹配工具参数。** 类似「当 `bash.run_in_background` 为 true 时禁用超时」的规则引擎会使策略插件了解工具特定的参数语义。通过不将 bash 迁移到工具调用超时来避免此问题。 + +**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这不可行,因为截止时间的生命周期将跨越两个独立的 waterfall:需要 call-id 映射、在每个 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。 + +**使用 `Promise.race` 为非协作式工具强制超时。** 否决,原因与超时库 RFC 相同:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。 + +## 后果 + +- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是窄的:包装注册表分发,而非替代 pre 门禁或 post 结果策略;基础 `next()` 是「分发并规范化」,因此包装器永远不会看到原始的工具抛出。 +- 多个 `tools/execute` 监听器通过普通的 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。组合超时与未来的重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 +- 按声明加入是一个有意的配置错误风险:工具可以声明 `timeoutMs` 但不遵守 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作式;web 工具在已经转发信号的工具上证明了这一模式。 +- 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍然是 bash 和钩子使用的 bash 后端超时。 +- 与字面提案的偏差,按已实现 RFC 规则记录:插件包名为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`),信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者),逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置)而非在此插件的配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在「## 决策」中描述。 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml new file mode 100644 index 0000000000..7f9d0625f3 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-08-agent-scope-contexts.md: f238d58d90413d36e81b34c1a2c94e1291e889de +2026-07-08-agent-scope-contexts.zh.md: dfade19709ccbf206054f414882ecff114ac1e44 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index dcff4d3220..f238d58d90 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -1,5 +1,7 @@ # RFC: The agent is a registration scope +English | [中文](2026-07-08-agent-scope-contexts.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md new file mode 100644 index 0000000000..dfade19709 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -0,0 +1,172 @@ +# RFC:agent 即注册作用域 + +Status: implemented + +[English](2026-07-08-agent-scope-contexts.md) | 中文 + +## 问题 + +一个应用需要在多个 agent(智能体)之间共享基础设施,同时让每个 agent 拥有自己的工具、prompt 贡献、策略和监听器。共享的适配器、持久化和用户界面属于部署层面;而一个人设、工具变体或监听器往往只属于某一个 agent。 + +为每个 agent 建立独立的服务图会重复共享基础设施。一个全局注册图则有相反的问题:某个 agent 的专属贡献可能泄漏到无关的 agent 中。贡献者需要一种普通的注册机制,既能决定谁能看到一项贡献,又能决定何时清理它。 + +该机制还需要一个发布边界。agent 在其本地世界完整之前不得变为可见,而拆除过程必须保留该世界直到最终工作停止。 + +## 决策 + +每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有该贡献的上下文进行注册;感知作用域的服务将部署全局注册与恰好一个匹配的 agent 层组合;操作从其真实 agent 选择该层;该层在 agent 完整的已发布生命周期内存在。 + +Cordis 是 SDK 底层的插件框架。Cordis **上下文(context)** 是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../cordis-primer.md)对框架有更详细的说明。 + +对大多数贡献者而言,完整的契约是四条规则: + +| 问题 | 规则 | +|---|---| +| 在哪里为某个 agent 注册行为? | 通过 `agent.ctx` 调用普通的注册 API | +| 某个 agent 的操作能看到什么? | 部署全局加上该 agent 的层,使用所属服务的合并规则 | +| 哪些作用域监听器会运行? | 无作用域监听器加上为该操作的 agent 注册的监听器 | +| 该层存在多久? | setup 在发布前完成;dispose 保留该层直到工作达到静止 | + +作用域是扁平的。解析永远不会遍历父级或兄弟作用域,生命周期所有权也不意味着注册继承。 + +```mermaid +flowchart LR + plain["Plain plugin context<br/>cleanup follows the plugin"] -->|"registers into"| globalLayer["Deployment-global layer"] + agentAContext["agentA.ctx<br/>cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"] + agentBContext["agentB.ctx<br/>cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"] + + operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view<br/>globals plus A local"] + globalLayer --> agentAView + agentALayer --> agentAView + operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view<br/>globals plus B local"] + globalLayer --> agentBView + agentBLayer --> agentBView +``` + +缺失的交叉边就是隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因为父级拥有子级的生命周期就进入子级。 + +配套的[运行时设计 RFC](2026-07-12-agent-scope-runtime-design.md) 解释了实现与正确性推理。[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 + +### 注册来源决定可见性与清理 + +通过普通插件上下文进行的注册是部署全局的,随该插件 dispose。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域 dispose。 + +| 注册来源 | 默认可见性 | 随谁 dispose | +|---|---|---| +| 普通插件上下文 | 每个符合条件的 agent 视图 | 注册插件 | +| `agent.ctx` | 仅该 agent 的视图 | agent 作用域 | + +工具、prompt 段落与变量、工具限制、守卫和作用域事件监听器都采用此契约。同名的本地值通常对该 agent 遮蔽同名的全局值;每个所属服务自行记录例外与合并行为。 + +普通贡献者的模式是在 agent setup 期间注册完整的本地世界: + +```js +const handle = await ctx.agents.create({ + agentId: AgentId('reviewer'), + sessionId: SessionId('reviewer-session'), + agentOptions: { model: 'model-name' }, + setup(agentCtx) { + agentCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: 'Review code, but do not modify files.', + }) + agentCtx.tools.register({ + name: 'review_summary', + description: 'Return the review summary.', + parameters: { type: 'object', properties: {} }, + async execute() { + return [{ type: 'text', text: 'review complete' }] + }, + }) + }, +}) + +ctx.tools.get('review_summary') // undefined: not global +ctx.tools.get('review_summary', handle.agent) // the reviewer-local tool + +await handle.dispose() +ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone +``` + +setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通的插件和服务。其契约仅限组合:通过强制转换或内部注册表调用来驱动或发布正在构建的 agent 是不受支持的。 + +### 操作选择视图 + +注册来源与操作主体是两个独立的事实。通过 `agent.ctx` 调用服务决定的是新注册归属何处,并不将后续读取绑定到该 agent。 + +工具查找与执行接收其服务的 agent。prompt 组装接收正在构建请求的 agent 的组装上下文。事件分发接收其领域主体。这使共享服务实例可在多个 agent 间复用,同时让每个操作的视图保持显式。 + +只有采纳了作用域契约的服务才会解析 agent 层。`agent.ctx` 不会自动改变任意 Cordis 服务调用的行为。 + +### 作用域事件将路由与事件数据分离 + +关于 Agent A 的事件通常到达无作用域监听器和 A 作用域监听器,而不到达 B 作用域监听器。没有 agent 主体的事件只到达无作用域监听器。 + +在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 + +以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。 + +### 创建最后发布,dispose 最后撤销 + +`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。 + +可选的创建信号仅在 create 或 resume 挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式的 dispose 权。 + +如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;所有失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 + +`AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷新,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 + +调用方的 Cordis 上下文和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。 + +```mermaid +flowchart TB + request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] + privateWorld --> setup["Await composition through agent.ctx"] + setup --> admission["Admit final session and agent entries"] + admission --> publish["Announce lifecycle and start the driver"] + publish --> live["Return AgentHandle"] + + privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] + setup -->|"failure, cancellation, or owner loss"| rollback + admission -->|"duplicate or owner loss"| rollback + publish -->|"listener failure or owner loss"| rollback + live -->|"handle or owner disposal"| quiesce["Stop and drain work"] + rollback --> quiesce + quiesce --> detach["Detach agent, then session"] + detach --> revoke["Dispose the agent scope"] +``` + +## 安全与权限是非目标 + +agent 作用域组合的是受信的同进程注册。它不沙箱化插件,不定义父到子的权限格,不在创建时冻结授权,也不保证子级不能做超出父级的事。 + +父级可以拥有一个可见工具比自身更宽的子级,因为生命周期所有权不捐赠也不封顶注册。持有 Cordis 上下文的插件同样运行在同一进程中,可以直接调用可用服务。 + +需要非升级保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式的未来授权 API、以及通用的能力/输出/终止标签均不在本决策范围内。 + +## 曾考虑的替代方案 + +被否决的设计要么将可见性与清理分离,要么只覆盖一个注册族,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。 + +### 向每次注册传递 agent 选项 + +类似 `tools.register(definition, { agent })` 的 API 在每个注册表中重复作用域管道,并允许可见性所有权与清理所有权漂移。通过 `agent.ctx` 注册使两个事实跟随同一个 Cordis 效果所有者。 + +### 过滤事件但保持注册表全局 + +监听器过滤能阻止错误的钩子运行,但无法限定工具 schema、可执行查找、prompt 段落、变量或其他已注册数据的作用域。agent 本地组合仍需临时的全局变更。 + +### 为每个 agent 创建一个服务图 + +所需的视图是共享部署服务加上一个本地注册层。每 agent 一个图会重复适配器,并使共享持久化、提供方注册表和应用启动复杂化。 + +### 继承父级注册作用域 + +父子关系描述的是生命周期和对话谱系,而非通用合并策略。层级查找会让无关服务意外继承,且在没有独立权限模型的情况下无法定义安全性。 + +## 后果 + +贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作上选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,teardown 保留本地行为直到工作停止。 + +代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等于权限,subagent 组合控制作为独立功能存在,而非隐藏在作用域语义中。 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml new file mode 100644 index 0000000000..c48d3db510 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-14-acp-agent-client-protocol.md: 0bb2a2f2e307b8f23a3a9ca98edb2a2d3b5df0a8 +2026-06-14-acp-agent-client-protocol.zh.md: 19744cb9f4d4b675586d4327a1da423fdea21b77 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md index bfd0e6a10e..0bb2a2f2e3 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md @@ -1,5 +1,7 @@ # RFC: Agent Client Protocol (ACP) support — drive the coding agent from external editors +English | [中文](2026-06-14-acp-agent-client-protocol.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md new file mode 100644 index 0000000000..19744cb9f4 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md @@ -0,0 +1,59 @@ +# RFC:ACP(Agent Client Protocol)支持——从外部编辑器驱动编码 agent + +[English](2026-06-14-acp-agent-client-protocol.md) | 中文 + +Status: implemented + +## 问题 + +harness 最初只通过 readline 循环暴露 agent(智能体)。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成状态、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的情况下取消某个对话。ACP 将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 + +桥接层必须保持 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` prompt,并声明 `loadSession`。 +- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回组合支持的配置选项。 +- `session/load` 在构造 agent 之前,先用持久化元数据校验请求的 cwd;在异步恢复期间预留 id;将 user/assistant/tool 事件作为 ACP update 回放;并报告恢复后的 config-option fold。 +- `session/prompt` 接受 text 和 resource link,拒绝不支持或空的内容,每个会话只允许一个 in-flight prompt,并在该 prompt 所属的 `turn/end` 时结算。错误 turn 拒绝 RPC;其他关闭 turn 的原因通过一个全覆盖的 ACP stop-reason codec 映射。 +- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的 prompt。 + +工具调用的呈现仍由工具自身负责。工具的 `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 中「每次工具调用都询问」的策略。一个带有 call id 的、针对桥接层所属 agent 的 `approval/request`,会变成该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。非本桥接层的请求或无 call id 的请求走委托路径;answerer 缺失或失败时保持 fail-closed。决定是否询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。 + +当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。出厂的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一个审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验,并写入两个所属旋钮事件。在 open turn 期间的切换立即追加;idle 状态下的切换在响应中叠加,并在下一次 `agent/prompt-submit` 时锚定,位于请求组装之前。在此之前它仅存于内存,因此崩溃后恢复的是持久化的 fold。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 仍为连接级。 + +桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述和自定义回答覆盖语义均被保留。 + +生命周期归属是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的 prompt、并行 dispose 每个 handle、等待循环静默和持久化刷盘,然后移除记录。流式通知失败被隔离,已消失的客户端无法破坏 agent turn。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。 + +精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);package README 是运维契约。 + +## 曾考虑的替代方案 + +**在 `tools/execute` 前置一个监听器,对每个 ACP 所属调用都询问权限**:否决。这会把权限策略硬编码进 UI 桥接层,即使没有策略要求也会询问,且无法服务执行开始后才产生的审批请求。共享的用户审批 seam 将机制、询问策略和 UI answerer 分离。 + +**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、idle 观察和 dispose 是 `dsh-agent` 上的接口级归属操作;UI 插件不需要依赖规则的例外。 + +**通过 ACP `terminal/*` 执行 bash**:否决。那会把执行移到 harness 之外,绕过其沙箱、凭证清理、任务归属、cwd 解析和会话日志。终端元数据仅用于呈现。 + +**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的旧接口。 + +**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用归属范围,且与协议传输竞争。应用组合拥有 stdout 纯净性。 + +## 后果 + +编辑器可以通过一条 ACP 连接创建、加载、prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 和每会话配置的持久真源。工具呈现与人工回答通道仍是可扩展的插件契约,而非 ACP 特有行为。 + +桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托,以及 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。 + +idle 状态下的 config 选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到 open turn 之前不具有持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件 turn 封闭且回放安全的代价。 + +## 验证 + +ACP 测试套件覆盖内存协议编解码、创建/加载回放、精确的 prompt 结算、取消竞态、不支持的内容、工具呈现、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/dispose 静默,以及 HMR(热模块替换)清理。快照和 built-bin 测试检验应用组合,真实 API 的 e2e 在无 key 时自动跳过。 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml new file mode 100644 index 0000000000..312106acaf --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-14-acp-multi-session.md: b96557d2d94711adb2183aa4f5dd8debf39c1de8 +2026-06-14-acp-multi-session.zh.md: 263e292e7161b789b8e06772deb0d2bc896f8de6 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md index 77fa2b4669..b96557d2d9 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md @@ -1,5 +1,7 @@ # RFC: Multiplex concurrent ACP sessions over one connection +English | [中文](2026-06-14-acp-multi-session.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md new file mode 100644 index 0000000000..263e292e71 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -0,0 +1,39 @@ +# RFC:在单连接上多路复用并发 ACP 会话 + +Status: implemented + +[English](2026-06-14-acp-multi-session.md) | 中文 + +## 问题 + +一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上维持多个活跃对话。如果桥接层只允许单活跃会话,就不得不额外启动进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台任务 id 都绝不能跨越会话边界。 + +## 决策 + +ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,供 agent 作用域的回调使用。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待生效的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造重复的 agent;不同 id 可以并发加载。 + +每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到来时结算;来自已取消的先前轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到单条记录,只调用该 agent 的队列感知取消路径。 + +权限归属使用同一个反向索引。ACP `approval/request` 应答器仅向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值仅折叠该会话自身的事件,待生效的空闲切换存储在该记录上,直到下一个轮次将其锚定。 + +后台 bash 任务携带一个不透明的 owner token,其值等于所属会话的 session id。`bash_output` 和 `bash_kill` 在读取或终止之前,会将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不授予访问权限。归属信息存储在执行器任务上,因此工具插件重载不会擦除它。 + +连接拆除时清空活跃 map,将每个待结算的 prompt 以取消状态结算,并并行 dispose 所有 `AgentHandle`。每个句柄停止并等待其循环结束,在仍挂载时刷新会话,注销 agent,然后移除会话。拆除操作被 memoize 并在客户端断开与插件 dispose 之间共享。 + +## 曾考虑的替代方案 + +**每连接单活跃会话**:否决。它增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。 + +**每会话一个 `ctx.extend()`**:否决。子上下文本身并不创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话的归属记录;agent 生命周期由 `AgentHandle` 拥有。 + +**以 agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是应当在插件重载后存活的跨边界标识。 + +## 后果 + +N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不为每个会话添加一套监听器,因此在长连接期间避免了监听器扇出。 + +桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于推迟状态。 + +## 验证 + +多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离和拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。 diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml b/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml new file mode 100644 index 0000000000..b7e263a61a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-15-code-mode.md: c64b6d6d8442e60240fa6c849833385ed50d71ff +2026-06-15-code-mode.zh.md: f94ef61dae180bad5ec604b5c7f9552eeecff593 diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 950c2b4bd7..c64b6d6d84 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -1,5 +1,7 @@ # RFC: Code Mode — the model writes TypeScript against the tool registry +English | [中文](2026-06-15-code-mode.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md new file mode 100644 index 0000000000..f94ef61dae --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md @@ -0,0 +1,132 @@ +# RFC: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](../../../architecture.md) 中明确标注的 open TODO),且**每个**中间 `tool-result` 都在下一次请求时重新进入模型上下文。 + +对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都把整个中间结果拖回上下文,无论模型是否需要。 + +Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单观察:LLM(大语言模型)写代码比发出工具调用更擅长,因为它们见过数百万行真实代码,而见过的人造工具调用 trace 相对很少。模型不再每步发出一个工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只取回它打印或返回的内容——而非所有中间结果。 + +工具呈现属于拥有工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位符:Node `worker_threads` 提供独立隔离区、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 现有的信任模型(见§信任姿态)。 + +## 决策 + +三项决策,各自在下方独立小节中展开: + +1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经过校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 放入系统提示词)、或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其规范贡献;协作式 prompt 组装的结果仍具权威性,请求头日志记录的正是该返回的呈现。 +2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../../implemented/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`**:每次运行启动一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——不需要 unsafe-acknowledgement 标志——因为 harness 已经提供了 `dsh-bash-local`,后者以严格**更大**的环境权限执行模型编写的任意 shell 命令。 + +### 注册表拥有模式 + +`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` 是一个保留的呈现传输通道,位于注册和限制层之外;直接 prompt 提供方和组装 waterfall 仍各自负责自己的贡献。 + +**与 `toolOrder` 的交互,预先声明:** 如果配置的 `systemPrompt.toolOrder` 命名了原生能力,则在 `mode: 'code'` 下会拒绝所有组装,因为这些名称不在该模式的协议校验范围内。这是正确行为,不是 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。 + +**SDK prompt 段落。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段落为作用域内可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 + +**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。作用域内的 `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` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是归一化的外层结果。其 `execute(args, exec)`: + +1. **构建绑定。** 一个 run 作用域的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对其参数做 JSON 归一化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己的不可变执行身份,并遍历完整的工具流水线。 +2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 作用域的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 +3. **静默后结算。** 运行时结算后,桥接 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后没有子调用可以追加。 + +**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的邻接性,而一个程序可以产生多个上下文。支持它需要一个复数通道或循环级别的子分发缓冲区。 + +**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要逐工具的并发安全元数据。 + +**呈现。** `run_code` 的渲染意图按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一段程序文本;`presentResult` → 一个 `generic` 卡片,内容为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 + +### 可观测性:`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<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。 +- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,以 `error` 字段解析。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端 rejection。 +- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` +- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——正交的结果按[防御性模式](../../../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 时查找可选的运行时,因此原生模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 后面替换实现,配对相应的 SDK 生成器。 + +### worker 线程运行时 + +`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包。每次 `run()`: + +1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。Strip-only 模式拒绝不可擦除的语法(`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` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。 +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` 限制总经过时间,包括未完成的等待。到期、取消和完成都会终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限都是经过校验的配置。 +6. **Dispose 至静默**:服务自身的 disposal 终止进行中的 worker 并*等待*它们退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。 + +### 信任姿态 + +worker 运行时提供的是封闭隔离,而非安全边界:模型代码可以触及 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门控,并额外提供空环境、堆限制、独立隔离区和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 + +### 模型看到的内容 + +SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时 catch 被 reject 的工具调用,并仅返回或打印应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可以与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 + +## 后果 + +切换到 `'code'` 的部署必须更新任何仅原生的 `toolOrder`。组装监听器负责维护任何被重写的协议表面的完整性。子分发保持序列化,桥接不会传播逐调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。 + +## 测试 + +- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、敌对绑定流量、空环境、structured-clone 回退、输出上限和 disposal 至静默。一个 built-package 测试在纯 Node 下运行 worker 入口。 +- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、作用域可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 归一化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。 +- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、生成的文件和精选的回答。 +- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段落、头部工具列表、分发事件和结果卡片。 + +## 曾考虑的替代方案 + +**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,且依赖监听器顺序。模型被提供哪些工具、以何种表示,是注册表的单一关注点:原生 schema 和 SDK 是同一可见存储的两种投影。 + +**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立隔离区、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。 + +**对原生工具调用做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志表面替换很容易添加,但仍然每次调用付出一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 + +**循环中的并行原生分发。** 往返开销的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策使两者兼容:当元数据就绪时,原生并行分发和逐工具绑定并行化一起解锁。 + +**始终排他(忠实于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是理想的,强迫每次编辑都通过程序会加重常见场景的负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用而不强加。 + +**逐工具可见性层级(此工具原生,彼工具仅 code)。** 推迟:它需要逐工具元数据和 `'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 的 prompt 开销,尤其在 `'both'` 下。** `.d.ts` 可以与它补充的原生 schema 相当大;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话开销;mode 是逐部署的;本 RFC 不做无条件节省的声明。何时偏好哪种模式的量化指导明确是上线后的学习。 + +**注册表范围增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥接和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 来约束:所有基底形状的东西都在 `ctx.codeRuntime` 后面。 + +**Structured-clone 值可以超出 JSON。** 因此工具绑定在分发前对参数做 JSON 归一化,确保每个执行的调用都可以被记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。 + +**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少了往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的相同并发安全元数据绑定。 + +**预算计量读取事件循环,而非标志。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending dispatch 无法暂停它」)对敌对程序是承重的。两侧都有单元测试(带 pending decoy dispatch 的热循环在 `computeMs` 时死亡;idle-on-slow-binding 存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过。 diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml new file mode 100644 index 0000000000..6aac0209ec --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-17-filesystem-tool-schemas.md: c2d3aa679599b1129a19b9082b9254ecf3103f12 +2026-06-17-filesystem-tool-schemas.zh.md: b13274c41da244d7d2a5fe6ff2064d8d5e0a9b42 diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index adad17472d..c2d3aa6795 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -1,5 +1,7 @@ # RFC: Filesystem tool schemas — model-facing read/write/edit shapes +English | [中文](2026-06-17-filesystem-tool-schemas.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md new file mode 100644 index 0000000000..b13274c41d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -0,0 +1,112 @@ +# RFC:文件系统工具 schema——面向模型的读/写/编辑形状 + +Status: implemented + +[English](2026-06-17-filesystem-tool-schemas.md) | 中文 + +## 问题 + +[文件系统能力 seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及 read-before-write/edit 检查所依赖的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) 两份 RFC 随后将该策略从 `ctx.fs` 移到了 `dsh-fs-policy` 插件的 `fs/*` 事件门上。第一版文件系统工具交付剩余的决策是面向模型的 schema 表面:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 + +schema 应当足够小,能在 `dsh-tool-fs` 的首次实现中完成;同时又足够稳定,使未来的本地/远程/沙箱文件系统后端不会引起面向模型的接口变动。它还应避免从参考系统照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有表面。 + +## 决策 + +`@deepseek-ai/dsh-tool-fs` 在第一版文件系统工具套件中暴露以下三个面向模型的工具: + +| Tool | 我们的 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 下需要先有一次观测(任何窗口化的 read 都算)。 | 是 | + +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` 暴露为面向模型的参数。stale-version 检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 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。它使用一种严格的字面替换模式,使面向模型的契约保持简单,后端可以自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。 + +## 结果形状 + +首次实现通过现有的 `ToolDefinition.execute()` 契约返回 `ContentBlock[]`。`ctx.fs` 返回结构化的文件系统结果并负责文件状态的记录/刷新;`tool-fs` 将这些结果格式化为模型投影。 + +默认原生投影: + +| Tool | `tool-fs` 消费的结构化 `ctx.fs` 结果 | 默认模型投影 | +|---|---|---| +| `read` | 返回的行、返回行数、总行数、目标显示路径、文件版本、部分视图标志 | 带行号的文本加分页脚注 | +| `write` | create/update 操作、目标显示路径、新文件版本 | 简洁的 create/update 成功文本 | +| `edit` | 替换次数、replace-all 标志、目标显示路径、新文件版本 | 简洁的 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**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。 +- **camelCase 参数名(OpenCode 风格)**:snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开表面。 +- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。stale 检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。 + +## 后果 + +**首版 schema 有意小于 Claude Code。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非在初始 schema 上叠加重载。 + +**v1 没有显式的面向模型 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:stale 检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非来自模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非通过模型提供的版本字段。 + +**命名成为公开表面。** 一旦发布,将 `file_path` 改为 `filePath` 或将 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选定 snake_case 并将其视为稳定的面向模型契约。 diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml new file mode 100644 index 0000000000..7f9212b3d3 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-18-acp-terminal-and-tool-rendering.md: cab89aa690c2068399ea5429a9c467410c744ce8 +2026-06-18-acp-terminal-and-tool-rendering.zh.md: 5a545b7a53f0814bc0e6071430c367047dc83f7f diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md index 3b8cd810a8..cab89aa690 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md @@ -1,5 +1,7 @@ # RFC: Rich ACP bash rendering — the terminal card via the `_meta` convention +English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md new file mode 100644 index 0000000000..5a545b7a53 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md @@ -0,0 +1,48 @@ +# RFC:丰富的 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 + +Status: implemented + +[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文 + +## 问题 + +ACP 桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 展示](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 + +参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格输出和退出状态;纯文本丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏了原始输入,而人类可读的描述保留为卡片上方的独立块。 + +## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create` + +ACP 规范有一个*客户端侧*终端子协议:agent 调用客户端的 `terminal/create`,传入 `{ command, args, cwd, env }`,由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境变量清洗、后台任务所有权、按会话的 cwd)。把执行路由到编辑器会绕过所有这些机制,并将执行分裂为两个后端。 + +研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理: + +- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 和 `_meta.terminal_info.{ terminal_id, cwd }`;输出/退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 和 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。 +- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量)发送,由同一个 `_meta.terminal_output` 能力选择。 + +Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`,label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。它将能力声明为 `clientCapabilities._meta.terminal_output = true`。`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*(`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范——但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一途径。 + +## 决策 + +保持 `dsh-bash` 的 agent 侧执行;通过 `_meta` 约定渲染终端卡片,以能力声明为门控,以 ` ```console ` 文本块作为保底回退。 + +1. **能力声明。** `initialize` 读取 `clientCapabilities._meta.terminal_output`,桥接层按连接记住它。 +2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可以返回它——提供方无关(`cwd`、输出 `data`、`exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash` 为 `bash` 返回该结构(cwd 来自解析后的工作目录;输出 + 退出从运行结果解析)。 +3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会**替换**调用的 content 集合,因此重发围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。 +4. **退出标记从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态标记(`_meta.terminal_exit.{exit_code,signal}`)会被发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。dispose 不受影响:没有新资源需要清理,因为桥接层从未创建客户端侧终端。 + +## 曾考虑的替代方案 + +- **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 消费方也会需要的丰富度。 + +## 不在范围内 / 非目标 + +文本块基线仍是无能力声明时的默认行为。两个后续工作有意不在此处构建,各自需要独立 RFC:**实时增量流式传输**(`_meta.terminal_output_delta`,在分片到达时发送,需要 `dsh-bash` 上的增量输出 seam),以及**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片、将 `grep` 解析为 `search` 等,回退到终端卡片——仅展示,绝不改变实际执行的内容)。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml new file mode 100644 index 0000000000..211ad897f7 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-18-compaction-capability-seam.md: 31b06905924a07a7f0c2af427d8868585966f1a7 +2026-06-18-compaction-capability-seam.zh.md: ef71b3df39f02221f1bd25beb5e026cb056b95a0 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 4a6586b159..31b0690592 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -1,5 +1,7 @@ # RFC: Compaction as a capability seam (abstract contract + basic backend) +English | [中文](2026-06-18-compaction-capability-seam.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md new file mode 100644 index 0000000000..ef71b3df39 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -0,0 +1,127 @@ +# RFC:压缩作为能力 seam(抽象契约 + 基础后端) + +Status: implemented + +[English](2026-06-18-compaction-capability-seam.md) | 中文 + +## 问题 + +长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口——模型随即在响应中途截断(`max-tokens`)或质量退化。**压缩(compaction)**是缓解手段:用一段简洁的摘要替换一段较早的历史,保持近期上下文完整。 + +[会话 surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而建的基础设施:它是事件日志之上的链表,带有一个 `surfaceOp: { op: 'replace', start, end }` 操作,专门用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 + +两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——这些与*何时*压缩、*压缩哪段*彼此独立变化。第二,`SurfaceEventType` 是封闭的,只有五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有它们可以携带 `surfaceOp`。因此一个专属的 `compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上放 `surfaceOp`,invariants 插件在运行时也会拒绝。 + +## 决策 + +### 压缩是一个能力 seam,接口与实现分离 + +按照[能力 seam RFC](../../implemented/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`,拥有完整算法——token 估算(每 token 字符数——`charsPerToken` 配置,默认 4——加逐块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 的摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是兄弟包(或覆写两个 protected 估算/摘要钩子的子类)。 +3. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 RFC 范围之外,以便 seam 先稳定下来。 + +### 契约依赖 `dsh-session` 和 `dsh-llm`——有意的偏离 + +能力 seam RFC 规定接口包「只依赖 cordis」(对 `dsh-bash` 成立,其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义在 `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约无法表达。 + +这不是耦合异味——而是契约的领域本身。「只依赖 cordis」的指导原则本来就是「接口只依赖契约真正命名的东西,绝不依赖实现」的简写。`dsh-session` 和 `dsh-llm` 本身就是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。 + +### 抽象的 `compactIfNeeded` / `compactRegion`,算法在后端 + +早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,只有 `estimateContentTokens()` 和 `summarize()` 是抽象的。这会把契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端不得不与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——接口则保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()` 和 `summarize()` 是 `protected` 钩子,子后端可以覆写而无需重新实现遍历——但这种分层是后端的私有关注,不是契约的。 + +`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` 接受**必填**参数(而非最初的全可选形态)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)以及轮次的 abort signal,因此可选性只会在 seam 处引入隐藏默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件已在那里选择实际模型。 + +### 自动压缩运行在 `agent/pre-step`,一个专用的 surface 变更 seam + +压缩会变更会话 surface,因此它在步骤开启之前、消息派生之前运行。`agent/request` 仍然是调用配置变换,永远不需要在 surface 变更后重建历史。 + +解决方案是一个专用的循环 seam:**`agent/pre-step`**(`@mode serial`),由循环在系统组装*之后*、步骤开启(`step/start`)*之前*触发: + +``` +assembly = ctx.systemPrompt.assemble() +await ctx.serial('agent/pre-step', agent, turn, step, system, signal) ⟵ compaction mutates the surface here +session('step/start') ⟵ the step opens AFTER the seam +messages = session.deriveMessages() ⟵ single derive, reflects the compaction +request = waterfall agent/request ⟵ pure request transform (hooks, model switch) +``` + +循环在 `agent/pre-step` 之后只派生一次消息。在 `step/start` 之前运行使压缩记录落在任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且 serial 的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。 + +### 保留是轮次无关的;工具配对平衡是唯一的结构守卫 + +自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:一个工具密集的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,surface 在*一轮之内*就会增长。单独一轮就可能超出窗口(「失控轮次」)——而在下一次模型调用溢出之前能挽救它的唯一时机,就是下一步的 `pre-step` 检查点。如果把压缩限制在轮次的第一步(或更糟,逐字保留整个进行中的轮次),就恰好重新打开了压缩存在的意义所要堵住的那个缺口:harness 会在最需要压缩的时候崩溃。 + +`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小尾部完整 surface 单元,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到截断处工具配对平衡。平衡按 surface 顺序检查,而非日志序列号,因为替换摘要在旧 surface 位置有新的序列号。`compactRegion` 拒绝将工具调用与其结果拆开的边界。进行中的轮次不享有特殊保留。 + +因此失控轮次的压缩方式与任何其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持逐字。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`),待该步骤关闭后重试。 + +**单单元溢出不在范围内,这是有意的。** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点如粘贴的 `user/message`——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地命名了这个边界,而非掩盖它。 + +### 头部锚定:一个自动检查点,始终在头部 + +自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,使自动检查点始终只有一个。因此 `shadowedRange` 是位置性的而非数值序列区间:一个更新的摘要序列号可能占据更旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。 + +### 近似收敛不变式 + +`resolveConfig` 校验数值参数但**不**基于假想的摘要长度不变式拒绝。收敛是动态的:提供方的输出上限可能被隐藏或外显的推理 token 消耗,模型可能输出不可预测大小的摘要。`maxTokens` 只是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于它遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能把保留尾部推过预算)——这恰好是上面声明的范围外关注点,而非抖动 bug。 + +### Surface 替换:`compact/*` 事件仅存于日志;一条 `user/message` 承载摘要 + +由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,其 `sourceEventSeqs` 覆盖被遮蔽的节点*以及*簿记事件。`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 → 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_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*就是* user 角色的上下文。 + +### 检查点框架 + 增量合并(后端私有) + +基础后端将摘要包装为已建立的检查点上下文,并标记它以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 只承诺一条替换 user 消息承载可能带框架的摘要。 + +### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败分类 + +`compact/start … compact/end` 括号的合理性,按实际承担的工作排序: + +1. **可检测的崩溃孤儿 + 来源记录**(首要)。摘要生成是一次慢模型调用,在 `compact/start` *之后*持久化。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先释放)将崩溃窗口从*静默损坏*转化为可检测的孤儿。 +2. **防止并发压缩。** 如果当前轮次持有一个未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是一个重入绊线——抛出的「already in progress」信号意味着真正的 bug。) + +两种失败路径,均有文档记录: + +- **崩溃**(循环在摘要生成中途死亡):一个悬空的 `compact/start`,没有关闭者。因为 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,所以完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。 +- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`,surface 不受影响,模型调用继续使用完整历史。 + +`compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。 + +**核心会话修复保持对压缩无感知——这是有意的。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。因为仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。 + +## 曾考虑的替代方案 + +- **完整算法作为接口上的具体方法**(只有估算/摘要是抽象的)——早期草案;否决,因为它把契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。 +- **压缩运行在 `agent/request` waterfall(瀑布式事件)上**——早期方案;否决,因为它强制了双重派生,且交给监听器的上下文在结构上无法压缩。专用的 `agent/pre-step` seam 使分层在构造上正确。 +- **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。 +- **教导核心轮次修复认识 `compact/*`**——否决:仅日志的孤儿是惰性的,而一个为每个未来 `xxx/start … xxx/end` 插件对打补丁的核心模块,恰好是能力 seam 架构存在的意义所要避免的耦合。 + +## 后果 + +- **新包**:`packages/compact/compact`(接口)和兄弟包 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。 +- **新循环 seam**:`agent/pre-step`(`@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。 +- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **不受影响**。这些是会话事件而非 cordis `Events`,因此事件分类门禁无需新增条目。 +- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆开步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在,直接复用。 +- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩会将高序列号的替换节点放在更旧范围的*位置*,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次包含不变式原样复用。 +- **接线**:`dsh-compact-basic` 在 `examples/coding-agent` 的 `cordis.yml` 中加载,使 seam 在真实演示中交付(此前未在任何地方加载)。 + +## 测试 + +- **单元测试:** 真实 Loader 和 invariant 插件覆盖整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。 +- **循环测试:** 测试固定每步在 `turn/start` 和 `step/start` 之间有一次 awaited 的 `agent/pre-step`;在那里的 surface 变更落在步骤之外,并出现在单次派生的请求中。 +- **带密钥 e2e:** 真实模型和 bash 会话在降低限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 +- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错的摘要调用回放仍是后续工作。 diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml new file mode 100644 index 0000000000..ec702a6101 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-21-subagent-capability-seam.md: 2bed84cd9166e8aa1ad5fa65b3afa44b8a842045 +2026-06-21-subagent-capability-seam.zh.md: a99d0fe894dca485452dd266752785a26815bb8a diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index e2871532c8..2bed84cd91 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -1,5 +1,7 @@ # RFC: Subagent capability seam +English | [中文](2026-06-21-subagent-capability-seam.zh.md) + Status: implemented > The full seam is shipped: the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `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 RFC](2026-06-22-acp-subagent-backend.md)). diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md new file mode 100644 index 0000000000..a99d0fe894 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -0,0 +1,74 @@ +# RFC:Subagent 能力 seam + +Status: implemented + +[English](2026-06-21-subagent-capability-seam.md) | 中文 + +> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([按会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外 `dsh-subagent-acp` 后端([其 RFC](2026-06-22-acp-subagent-backend.md))。 + +## 问题 + +harness 有一个长期搁置的 subagent seam:一个 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)):创建选项引用父 agent(fork = 用父会话的事件日志为子会话播种;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅统一工作。本 RFC 实现该 seam;上方横幅列出了已交付的内容。 + +决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。我们预见的传输方式: + +- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于已有的 agent 工厂几乎零成本); +- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); +- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外「启动子 agent、发送提示词、流式更新、取消」形态。 + +## 曾考虑的替代方案 + +### 为什么不用 bash seam 的形态 + +bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**:每个实现以唯一名称注册,调用方按名称选取。这与 **LLM 适配器注册表**(`LlmService.registerAdapter`)同构,而非单服务的 bash 执行器。seam 仍然是三包结构(接口 / 实现 / 消费方);唯一不同的轴是「单实现 vs. 多实现」。 + +## 决策 + +### 三包 seam + +新增包组 `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 | +| `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 | +| `@deepseek-ai/dsh-subagent-mock` | 支撑:脚本化的提供方,用于通过真实加载路径测试 seam | +| `@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` 以仅包含已完成父轮次的平衡前缀为种子。进行中的轮次被排除,因为其 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 中没有 provider/type 参数。 + +## 测试 + +seam 通过真实的 Cordis Loader/export 路径测试,这能捕获 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[按会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e。 + +## 后果 + +- **递归。** 若无限制,进程内子 agent 能看到委派工具并递归。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有它们的确切语义和安全限制。 +- **阻塞父轮次。** 同步收集在子 agent 的整个持续期间保持父 agent 的 `runStep` 打开。这对第一版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该重新设计将统一 subagent 与 bash 的长时运行工具处理**(一个 sub-agent 和一个长时间运行的 `bash` 后台任务面临相同的「模型启动了一个慢操作,之后如何收集结果」问题,应共享一套机制而非各自发明)。 +- **实时进度。** 本版仅暴露生命周期事件和最终结果;逐分片的子→父更新流推迟到后台重新设计。 +- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;第一版不声明这两项能力,子 agent 在自己的进程中自给自足。 diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml new file mode 100644 index 0000000000..a34398b188 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-22-acp-subagent-backend.md: 7eb03ddf68f54c29524944e7b8bc801eb1724fe6 +2026-06-22-acp-subagent-backend.zh.md: 6f7b95318a2c714fea43a584ba49da00a7c12040 diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index 0b98adb0de..7eb03ddf68 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -1,5 +1,7 @@ # RFC: ACP subagent backend (out-of-process delegation) +English | [中文](2026-06-22-acp-subagent-backend.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md new file mode 100644 index 0000000000..6f7b95318a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -0,0 +1,57 @@ +# RFC:ACP subagent 后端(进程外委派) + +Status: implemented + +[English](2026-06-22-acp-subagent-backend.md) | 中文 + +## 问题 + +subagent seam(见 [seam RFC](2026-06-21-subagent-capability-seam.md))的设计使得多个后端可以按名称共存于 `ctx.subagents` 上。进程内后端(`-spawn`/`-fork`)将子 agent 作为同一个 Cordis 上下文上的第二个 `Agent` 运行——开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义正是还要支持通过协议到达的进程外子 agent,以证明这层抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent 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` 通过第一个 allow 形态的选项批准)——本次实现不将任何提示呈现给人类。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍是未来工作,如 seam RFC 所述。 + +### 无启动时能力 + +提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无法访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),且本次实现未实现 `outputSchema`。服务在 `start` 运行之前就会拒绝需要上述任何能力的请求。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`。 + +### 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 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载,以及命名空间导出。 +- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`、写入 `proof.txt`,父进程验证该文件。 +- **快照缺口:** 每个 ACP 子 agent 是独立进程、拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock-server 覆盖已有;`TODO(acp-subagent-replay)` 跟踪父 agent 对回放中子 agent 的回放支持。 + +## 曾考虑的替代方案 + +### 为何继续使用 SDK 0.25.1? + +后端仅需 `ClientSideConnection`、`ndJsonStream`、`PROTOCOL_VERSION` 和客户端协议类型,0.25.1 均已支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,但不会改善本后端,因此升级作为独立变更保留。 + +### 为何不使用持久子进程? + +持久进程池(跨运行复用热子进程)是一项性能优化,推迟到未来工作——它引入会话生命周期和崩溃恢复的复杂性,本次实现不需要;每次 `start` spawn 新子进程与进程内「每次运行一个子 agent」的形态一致。 + +## 后果 + +每次运行都要付出一个新子进程的开销(spawn + `initialize` + `newSession`)。父 agent 仅呈现子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示永远不会到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥须通过 `config.env` 显式提供。 + +## 未来提供方 + +同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam RFC 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml new file mode 100644 index 0000000000..c46a38e46f --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-25-ask-user-question.md: 06673233038d10214f8de3d1f29766d43b575442 +2026-06-25-ask-user-question.zh.md: 01d1284dba3622984d5403f94e3edd0ba02583b6 diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md index d7c6631b94..0667323303 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -1,5 +1,7 @@ # RFC: Ask-user question capability +English | [中文](2026-06-25-ask-user-question.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md new file mode 100644 index 0000000000..01d1284dba --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md @@ -0,0 +1,51 @@ +# RFC: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 产品表面提供收集答案的具体 provider。工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。 + +模型侧的请求词汇有意与产品研究 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`。`id` 按问题提供并在结果中回传,使批量请求可以路由而不依赖问题文本。`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。 + +provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形态。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖所有已选选项,`selected` 为空。 + +`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER`、`ASK_ABORTED`、ACP 取消或会话路由缺失等失败会以可机器路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能区分「用户取消」与通用抛出异常。 + +## UI 映射 + +`dsh-stdio-demo` 的包内 readline 模块逐题渲染每个问题,在下一行展示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序逐题询问,合并为一个答案对象返回。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个 prompt 占用 stdin。 + +`dsh-acp` 为 ACP(Agent Client Protocol)会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由到对应会话,并为每个问题调用 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 prompt 到达,而非作为需要答案的那次操作的结果。 + +**核心包拥有 ask-user 相关包。** 最初实现将 seam 和模型侧工具分别放在 `packages/core` 和 `packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互能力。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction` 和 `dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包结构与产品边界一致:应用和 bridge 提供人类答案的 provider,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` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和模型侧工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶子节点必须在其客户端能够完成 elicitation 请求后才有意加载模型侧工具。 + +## 测试 + +单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc` 的验证)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流程。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。 diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml new file mode 100644 index 0000000000..d2c28a3946 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-29-todo-write-tool.md: 69f81cf6fd93df63ce53bb82c97dbac16dbbd486 +2026-06-29-todo-write-tool.zh.md: a687f5ab4bc5b9fcd5583ca4aac2857ab4c3f513 diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md index 126c69f1c0..69f81cf6fd 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md @@ -1,5 +1,7 @@ # RFC: The `todo_write` tool — model task list as event-sourced session state +English | [中文](2026-06-29-todo-write-tool.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md new file mode 100644 index 0000000000..a687f5ab4b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -0,0 +1,64 @@ +# RFC:`todo_write` 工具——将模型任务列表建模为事件溯源的会话状态 + +Status: implemented + +[English](2026-06-29-todo-write-tool.md) | 中文 + +## 问题 + +harness 为模型提供了 bash 和 subagent 工具,但没有任何方式记录结构化的任务列表。todo 列表服务于两个同等重要的目的:引导模型规划多步骤工作并保持当前任务明确(最多一个 in_progress,有未完成工作时恰好一个),以及为人类提供实时进度清单。ACP(Agent 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* 状态,伴随对话传播但不属于对话的一部分。(开发模式的不变式仍要求它位于一个打开的轮次内,事实也确实如此:它在工具调用的 mid-step 阶段追加。) + +### 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 的 item 是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但那只是为了支持 agent *集群*(磁盘持久化、锁保护、逐项变更)。本工具将 item 保持在最小集:`{ content, status }`。没有 `activeForm`(现在进行时标签)——UI 直接展示 `content`;没有 id——全量替换不需要稳定标识;没有 priority——见上文。每去掉一个字段,模型每次调用就少产出一项。 + +### 单一所有者——无集群机制(YAGNI) + +每个列表属于调用方 agent 会话,非 agent 调用会被拒绝。没有共享作用域、resolver 或 delta 协议。跨 agent 列表需要逐项日志 delta 和显式作用域选择,因此留作未来独立设计。 + +### 校验:低成本的中间路线 + +schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content` 以及多于一个 `in_progress` 任务。claude-code 将 single-in-progress 留给 prompt;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` 更新。 +- **带 key 的 e2e + 快照**——一个真实 prompt 诱导 `todo_write`;快照 golden 新增 `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 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml new file mode 100644 index 0000000000..5402e85e81 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-hook-bridges.md: 17ff57307c34121c845592efa93c723e66c98886 +2026-06-30-hook-bridges.zh.md: 2a94d5cca2f490e4aac493fe357a825ad3b4d271 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index 2d285fb152..17ff57307c 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -1,5 +1,7 @@ # RFC: dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges +English | [中文](2026-06-30-hook-bridges.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md new file mode 100644 index 0000000000..2a94d5cca2 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -0,0 +1,70 @@ +# RFC:dsh-hooks-claude + dsh-hooks-codex——Claude Code / Codex 钩子桥接插件 + +Status: implemented + +[English](2026-06-30-hook-bridges.md) | 中文 + +## 问题 + +harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](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 Code(CC)和 Codex 钩子配置到来——一个 `hooks.json`(或设置文件中的 `hooks` 键)里满是 shell 命令钩子——并且希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,基于共享的协议格式(wire format)库(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md))构建。 + +贯穿整个设计的定位是:**桥接是兼容性适配器,不是高级工具。**桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能更强力地完成——有类型化返回值、完整的 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射到 seam 的 Decision。各 package 的 README 记录了当前相对官方协议的不支持事件与部分字段清单。 + +## 决策 + +`packages/hooks/` 分组下两个独立插件,各自为函数/命名空间插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../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`/`cwd`/`hook_event_name`,加上每事件特有字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则匹配模式。CC 钩子的 stdin 带有**尾随换行**。 +- **`dsh-hooks-codex`**——Codex 当前钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形状的 snake_case payload(带 `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾随换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接的精简 `tool_input: { command }` 形状中携带真实的 `tool_name`。 + +### 结果 → Decision 映射 + +每个桥接将共享库返回的中性 `MergedHookOutcome` 映射到 seam 的类型化 Decision: + +| Seam | CC | Codex | +|---|---|---| +| `agent/session-start`(emit) | additionalContext → `agent.inject()` | plain-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 → 注入进程内活跃子 agent;远程子 agent 没有本地注入目标 | 本桥接不支持 | +| `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,再 fold + +仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext` 折入下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。 + +### 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 RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为预执行参数被 `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`);共享 merge 将其折入 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一起推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(decision/上下文)。 +- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型均未重新实现(`TODO(per-session-hook-config)`)。 +- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动之外,因此其上下文在就绪时注入,但可能错过第一个请求或短命子 agent。保证首请求送达需要一个 awaited 的启动 seam。 + +## 曾考虑的替代方案 + +**同一点的钩子并发执行。** 参考引擎对同一点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行它们(匹配循环内逐钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)且逐钩子超时不重叠——对真实配置使用的钩子数量而言可接受;如果某天配置扇出到足以影响挂钟时间,再重新审视。 + +## 后果 + +匹配语义、退出码处理与合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支加上通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形状。原生插件绕过协议格式,直接返回类型化决策。 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml new file mode 100644 index 0000000000..22f7710469 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-hook-protocol-lib.md: 924c320f7ef9fdb55b20ff06f492addbf42d1720 +2026-06-30-hook-protocol-lib.zh.md: 2f1cf0f1e4c99eaf1172642475e3b9bc8c8aed39 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md index ac28345791..924c320f7e 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -1,5 +1,7 @@ # RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core +English | [中文](2026-06-30-hook-protocol-lib.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md new file mode 100644 index 0000000000..2f1cf0f1e4 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -0,0 +1,32 @@ +# RFC:dsh-hook-protocol——Claude Code / Codex 钩子协议格式的共享核心库 + +[English](2026-06-30-hook-protocol-lib.md) | 中文 + +Status: implemented + +## 问题 + +钩子子系统提供两个桥接插件:一个运行用户已有的 Claude Code(CC)钩子,一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。**它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型——Codex 的源码甚至以 Claude 的引擎命名自己的引擎,并在注释中标注了「有意偏离」之处。因此两个桥接插件如果各自实现,将重复协议的大部分内容。 + +本 RFC 引入 `@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` 始终为无锚定正则。缺失/`''`/`'*'` 时匹配全部;无效正则匹配空集(绝不向循环抛出异常)。 +- **Execution**——`runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已经提供了经过清理但可覆盖的 env、进程组 kill 和超时——正是协议所需的能力,而 `dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),尊重钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛出异常(执行器的 rejection 变为 non-blocking-error 的 `HookOutput`)。 +- **Decode**——`parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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,因此没有什么可抑制的;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 +- **Merge**——`mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block 原因以 `\n\n` 拼接,context/system-messages 按序累积。 +- **`hook/*` 会话事件**——`hook/invoked` / `hook/result`,通过 declaration-merge 加入 `SessionEventMap`(仅记录日志,类似 `compact/*`——不是 `SurfaceEventType`),附带 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对和轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义——决策字符串(钩子解析出的 decision,否则在 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从此处的 `HookOutput` 导出,而非在各桥接插件中分别实现。 + +**方言各自持有(桥接插件):**构建每个事件的 stdin payload(CC 的 base + per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射到 harness 的 seam 特定类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 + +## 曾考虑的替代方案 + +**一个参数化引擎。** 否决,因为 payload 构建和决策映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享;各桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。 + +## 后果 + +每个桥接插件解析配置、构建方言 payload、调用共享的 runner 和 merge 逻辑、映射决策、追加 `hook/*` 事件。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已被解析,但在 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地之前仅记录日志并发出警告。 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml new file mode 100644 index 0000000000..b40109802a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-interception-seams.md: fb8efe1e1c2057db13b440881f110ca7f579a81e +2026-06-30-interception-seams.zh.md: b22b3d61bd14b6708e5a063f02537e981fead0fc diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..fb8efe1e1c 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -1,5 +1,7 @@ # RFC: Interception seams — the typed-Decision surface a hook programs against +English | [中文](2026-06-30-interception-seams.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md new file mode 100644 index 0000000000..b22b3d61bd --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md @@ -0,0 +1,60 @@ +# RFC:拦截 seam——钩子编程所面对的类型化 Decision 表面 + +Status: implemented + +[English](2026-06-30-interception-seams.md) | 中文 + +## 问题 + +harness 需要一套钩子子系统:用户在生命周期节点扩展或拦截 agent(智能体),方式类似 Claude Code(CC)和 Codex。驱动本设计的关键重构是:**"原生钩子"不是一个 package**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是把外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件都能直接做——而且更强大(没有序列化边界、完整的 `ctx`、类型化的返回值)。 + +这个表面需要为以下各阶段提供不同的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及附带面向模型原因的继续。如果把这些阶段混为一谈,插件就会获得不需要的修改通道,终态也会依赖监听器顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则和类型化 Decision 惯用法;本 RFC 将它们应用到生命周期 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, next) → PromptDecision`——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。 + +**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化的孪生。 + +### 工具流水线为每个阶段赋予一种权限 + +每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → `tools/result`。注册表快照调用方输入、物化并冻结参数、分配不透明 token。嵌套调用只携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「运行了什么」达成一致。 + +- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 和核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均归一化为拒绝。每种结果仍会到达后策略和最终观测者。 +- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的作用域感知策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。 +- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已归一化的抛出或未知工具结果;返回自己的有效结果可短路调度。 +- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的就地修改不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。 +- **`tools/result`** 是每次变换、无损 JSON 物化和外层错误边界之后的同步受限通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者失败按监听器隔离,不能改变或拒绝 `ToolRegistry.execute()` 返回的结果。 + +核心调度和工具体位于归一化边界内,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状失败都解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查抛出异常的工具,最终观测者看到的恰好是调用方收到的、会话日志可持久化的内容。 + +**`TurnEndReason.rejected`**(`dsh-session`):整个 prompt 批次被 `prompt-submit` 阻止的轮次。 + +### 三个承重的循环决策 + +1. **在 prompt 策略之前开启轮次。** 被完全阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP 提供持久的终止事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留了被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。 + +2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤缓冲每次调用的上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。 + +3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使下一步骤的循环顶部 drain 将其记录为继续轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与既有的 `hasSteering` force-continue 覆盖一致)。 + +### Pre-tool 输入重写是一个独立的一致性决策 + +`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/*` 协议。压缩(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终止的单调停止由 `agent/turn-stop` 独立负责。 + +## 曾考虑的替代方案 + +- **将 pre-tool 输入重写作为本 seam 集的一部分交付**——推迟,视为过度扩展信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。 +- **将持久的 `hook/*` SessionEvent 与 seam 一起声明**——否决:原生插件使用类型化 Decision 而完全不需要钩子日志(工作示例已证明),因此持久日志属于[钩子协议库](2026-06-30-hook-protocol-lib.md),而非 seam 表面。 + +## 后果 + +规范的拦截表面实现了统一类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终止 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存和五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) 和[工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml new file mode 100644 index 0000000000..03da25497d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-session-store-fork-api.md: 4bf5c3fe43821570fd947034358e54d0a0a602f9 +2026-06-30-session-store-fork-api.zh.md: 3dd15f5beb095fecb7fdaa7d80abcf4b99c07920 diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md index 8d8813563b..4bf5c3fe43 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -1,5 +1,7 @@ # RFC: SessionStore fork API +English | [中文](2026-06-30-session-store-fork-api.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md new file mode 100644 index 0000000000..3dd15f5beb --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -0,0 +1,43 @@ +# RFC: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`,以及悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子会话历史——看起来像是参与了父会话中一个未完成的轮次。现有的 [subagent seam](../../implemented/feature/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 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久性工作都委托给现有的会话存储与持久化后端。 + +存储暴露一个操作: + +```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` 服务。** 这是第一版实现,但评审表明它过度套用了能力 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 出的子会话以种子事件开始生命,因此现有后端只需持久化一次该种子,并在头部保留 `parentSession` / `seedLength`。 + +v1 范围仍排除 ACP `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 获得专注的 `dsh-session` 单元测试加 JSONL 持久化覆盖。 diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml new file mode 100644 index 0000000000..085f5d2c5b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-subagent-observe-enrich.md: b48a1fff32130345e669a3b3b905c4fda987e41e +2026-06-30-subagent-observe-enrich.zh.md: 59dc555ce4acff30f4ba0b5929b605dc5252fe38 diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md index aa853edd24..b48a1fff32 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -1,5 +1,7 @@ # RFC: Subagent lifecycle enrichment — lastAssistantMessage (observe-only) +English | [中文](2026-06-30-subagent-observe-enrich.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md new file mode 100644 index 0000000000..59dc555ce4 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md @@ -0,0 +1,31 @@ +# RFC:Subagent 生命周期充实——lastAssistantMessage(仅观测) + +Status: implemented + +[English](2026-06-30-subagent-observe-enrich.md) | 中文 + +## 问题 + +钩子子系统([拦截 seam RFC](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`)——不足以让钩子桥接层在不另行访问活跃运行的情况下报告 subagent 产出了什么。 + +本 RFC 充实 end 载荷。它刻意限定为**仅观测**:不改变控制流,不引入 waterfall(瀑布式事件)。影响运行的 subagent-stop 决策(续行、注入改变运行的内容)属于另一项更大的重新设计,不在本 RFC 范围内。 + +## 决策 + +**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结算路径上,它是只读的类型化 `SubagentResult.output`,观测者无需持有运行即可看到子 agent 的产出。在基础设施拒绝、不存在 `SubagentResult` 的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听者是受信任的同进程协作者,遵守借用不可变载荷的契约。 + +两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观测附加到就绪的提供方运行上,发出 `subagent/start`,然后返回该运行;因此进程内监听者可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程提供方无需在本地注册表中有条目。提供方启动被拒绝时不发出任何事件。回调保持仅观测,逐监听者隔离确保一个坏订阅者不会阻塞活跃运行或饿死后续监听者。 + +## 曾考虑的替代方案 + +**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物)放在请求和两个生命周期载荷上——早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(这里没有任何代码解释它,唯一的消费方是 CC 方言桥接层)。CC 桥接层改为向 Claude Code 自身的 SubagentStart/Stop `agent_type` 匹配器喂入其默认值 `"general-purpose"`,因此本 RFC 只交付一项充实:`lastAssistantMessage`。 + +**控制流式 `subagent/end`**——推迟;见下文。 + +## 为何仅观测,以及推迟了什么 + +控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听者、在进程内提供方中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重新设计(同一项重新设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观测充实;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在该重新设计发生时将落地的位置。 + +## 后果 + +钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器——无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md)(事件行文部分)和两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件的触发方式与之前完全相同,end 载荷多了一个(可选的)字段——因此不需要快照或 e2e 测试变更。 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml new file mode 100644 index 0000000000..04a6289216 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-05-dynamic-workflows.md: 67ceebf7017f197bd800fd339b575390b3b936c1 +2026-07-05-dynamic-workflows.zh.md: 0ea8e88bfa750a9bb253c7dd3061766fe15d3630 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 6302584103..67ceebf701 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -1,5 +1,7 @@ # RFC: Dynamic workflows — a script-driven multi-agent orchestration seam +English | [中文](2026-07-05-dynamic-workflows.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md new file mode 100644 index 0000000000..0ea8e88bfa --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -0,0 +1,80 @@ +# RFC:动态工作流——脚本驱动的多 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`。pipeline 各阶段接收 `(prev, item, index)`,阶段间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过 journaling 延后处理,因此兼容的脚本正文在将 meta 头移入参数后,可以使用时钟和随机数。 + +与 Claude Code 的一处刻意**偏离**:钩子误用——未知或延后的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——抛出 `fatal: true` 的 `WorkflowError`,组合器对 fatal 错误**重新抛出**而非将 item 置为 null。如果不这样做,一个拼错的选项会溶解为与子 agent 失败无法区分的 `null`——正是本仓库禁止的「接受后静默忽略」失败模式。一处**新增**:工具的 `args` 参数是 JSON 对象(裸列表会被包装为一个字段),以保持协议格式(wire format)的诚实。 + +### seam(dsh-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](../../../core-data-structures/workflow.md)。 + +### 引擎(dsh-workflow-workerthread):每次运行一个 worker 线程 + +**信任前提**:工作流脚本与模型的 bash 访问享有相同信任级别。引擎约束有 bug 的脚本,保证 result 必定 settle、值 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 之后放置一个独立进程或 isolated-vm 引擎。 + +**为何选择 `node:worker_threads`**:每次运行获得一个非池化 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。 + +宿主在发布前校验元数据并解析正文。私有枚举键的 payload map 定义协议格式;待启动记录、已发布的子记录、单一取消信号、worker 死亡回收、result 优先级和 dispose 静默在协议两侧维持 subagent run 契约。[agent-scope runtime-design RFC](../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、超时和 grace 限制均为经校验的配置。 + +### 消费方(dsh-tool-workflow) + +一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、等待、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略作为工具自身的 `tool:<toolName>` prompt 段随工具一起交付(显式请求才使用的指导——工具指导存在于工具插件中,从不放在部署 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。校验失败仍为可重试的工具错误;干净完成但没有已提交捕获的情况 settle 为错误。 + +`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。[agent-scope runtime-design RFC](../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`,带 key 的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。 + +## 延后(本轮明确的非目标) + +- **后台收集**(启动工具 → run id → 完成通知 → 收集),与 bash/subagent 后台统一一起设计。 +- **Journaling + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会将 Claude Code 的确定性禁令作为脚本契约收紧重新引入(脚本今天可以读取时钟)。 +- **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到 run 目录**(tool-call 事件已经持久记录了脚本)。 +- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延后项的消息大声拒绝)。 +- **整体运行的挂钟超时**:取消总能释放调用方(result 在 grace 内 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 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。 +- **后台执行作为默认**(Claude Code 的形态):延后。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash/subagent/workflow 之间统一设计一次,而非逐工具各做一套。 +- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 的关注点,而 seam 的能力标志仍不诚实地为 `false`。 +- **Meta 嵌入脚本内作为 `export const meta = {...}`**(Claude Code 的精确格式):保持脚本自包含且 Claude Code 脚本可直接使用,但获取 meta 需要在宿主上对模型编写的文本求值。即使是空的限时 vm 上下文,在宿主读取结果对象时也无法约束脚本控制的 getter。JSON 参数消除了扫描器、求值和宿主自旋漏洞;代价是 Claude Code 脚本的 meta 头必须移入参数(正文保持可直接使用)。 +- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,且无法在不丢失转换精度的情况下对其校验。 +- **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 启动和 message-port RPC 的开销,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。Worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run 句柄保持控制,观察者仅接收快照。 diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml b/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml new file mode 100644 index 0000000000..0db4b2ac5a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-05-skill-system.md: 6cfd1f977ae5a1e1ad646a707a4201e57d46bc38 +2026-07-05-skill-system.zh.md: d491899e03854140c93f67d11e4079a5b6525185 diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.md index 0b74aa00ae..6cfd1f977a 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.md +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.md @@ -1,5 +1,7 @@ # RFC: Skill system — progressive disclosure instructions for agents +English | [中文](2026-07-05-skill-system.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md new file mode 100644 index 0000000000..d491899e03 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md @@ -0,0 +1,55 @@ +# RFC: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 与 ACP 应用获得相同行为,同时嵌入式或远程提供方可在不改动注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的负责方。 + +提供方插件在 `apply()` 期间同步注册。提供方成员关系是直接由 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射,而非监听注册表变更事件。提供方目录从 awaited `list()` 调用返回排序后的候选项,远程提供方在此期间执行初始化、认证和发现,同时遵守查找的 abort signal。注册表校验每个候选项,对同名 skill 按 rank、提供方注册顺序和提供方内部顺序执行 first-wins 解析,然后按 skill 名称排序摘要以保证消费方获得确定性结果。注册表仅缓存已完成的目录快照,当提供方/运行时修订版本在发现过程中发生变化时重试,因此 unload 不会将一个陈旧、不可解析的 skill 冻结进会话前缀。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project-over-user 优先级;`runtime` 作为注册表持有的提供方名称被保留。 + +本地提供方按 first-wins 的 rank 顺序扫描对 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` 包解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求所声明的现代解析器,手写窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 + +本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。对于未挂载 fs seam 的最小上下文,Node 文件系统仍作为回退。缺失的根目录、不可读或格式错误的 skill 文件,以及提供方 `list()` 的瞬态失败均降级为 warn-and-skip,使单个坏源不会导致每个 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。 + +`dsh-tool-skill` 通过 [`agent/session-prefix`](2026-07-07-session-prefix.md) 贡献一条 user-role `<system-reminder>` 目录。目录仅包含排序后的 skill 名称和描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 限制,其默认值为 `500`,最小值为 `3`。会话前缀 seam 将仅用于请求的目录按 loop 实例冻结,并记录在请求头中,在不将其加入持久化历史的前提下保持可重建性。完整 skill 正文从不包含在目录中。 + +`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的披露路径。 + +数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md),服务签名见生成的[服务目录](../../../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 编写 skill 物化到 `~/.dsh/skills/.system`。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。 + +**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包已覆盖配置的根目录,同时保持重复处理和目录顺序易于推理。 + +**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 + +## 后果 + +agent-core 主干包含一个会话前缀贡献者、一个本地提供方和一个面向模型的工具。skill 发现对 cwd 敏感,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。 + +目录在固定的根目录集和运行时注册修订版本下是确定性的,但不监听磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。 + +## 延后 + +fork 式 skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`),以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、不强制执行这些字段。 diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml new file mode 100644 index 0000000000..2e89e359db --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-approval-seam.md: 3ef51c31216bf9f0c5d945748ab3f901ec82147c +2026-07-06-approval-seam.zh.md: cec1692d509a7c9a0680773fbdbbb18e1c90ab8c diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 4ac066e393..3ef51c3121 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -1,5 +1,7 @@ # RFC: The approval seam — one-shot permission decisions over a waterfall of answerers +English | [中文](2026-07-06-approval-seam.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md new file mode 100644 index 0000000000..cec1692d50 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md @@ -0,0 +1,138 @@ +# RFC:审批 seam——通过应答者瀑布式事件实现一次性权限决策 + +Status: implemented + +[English](2026-07-06-approval-seam.md) | 中文 + +## 问题 + +两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute` 的 `ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 RFC](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们不必各自发明结果词汇、UI 路由、取消机制和审计追踪,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。 + +路由问题的本质是归属:审批提示必须到达拥有发起请求的 agent 的那个编辑器会话(ACP 桥在一条连接上复用 N 个会话),对无人拥有的 agent(进程内 subagent、测试)默认拒绝(fail-closed),并且不介入没有组合 UI 的部署(无头模式、CI)。 + +## 决策 + +一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制(MECHANISM)。策略(POLICY)——谁来应答、以及某个会话是否被询问——位于其外部:应答者是 `approval/request` waterfall(瀑布式事件)监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭的结果,并从中派生各自的工具结果。刻意只用一个包,而非能力 seam 的三包拆分(见「曾考虑的替代方案」)。 + +### 部署如何使用它 + +一条 `cordis.yml` 条目挂载该 seam。不加载它即为 fail-closed 退出方式:消费方在没有注册任何审批代码的情况下拒绝无法应答的请求。 + +```yaml +- id: approval + name: '@deepseek-ai/dsh-user-approval' + # config: + # policy: never # deployment default for sessions without an override; 'ask' when omitted +``` + +仅有这条条目提供的是机制而非通道:没有组合应答者时,每次 ask 解析为 `unavailable`,发起 ask 的工具调用被拒绝——默认拒绝无需配置。组合 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` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同的原因拒绝,模型可以区分它们;每次 ask 都在发起请求的 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"}` 结束:什么都不执行,模型的结果携带发起方逐字的 fail-closed 文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。无头模式下,同一请求完全跳过提示并以 `unavailable` 结算。 + +### 设计细节 + +#### seam:机制与策略分离 + +经过校验并追加 `approval/asked` 后,`request()` 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读请求、运行应答者 waterfall、与取消竞争,并将抛出异常或无效应答归一化为 `unavailable`。随后追加匹配的 `approval/decided`,通过 `ApprovalRequestId` 配对。 + +两个审计事件都必须在一个打开的轮次内;接受或 pre-commit 追加失败会拒绝该请求。Post-commit 观察者被会话所包含。`allowed-once` 仅授权所请求的操作,服务不保留任何授权状态。 + +应答者是 `approval/request` waterfall 监听器。监听器为其拥有的 agent 返回结果,否则调用 `next()`。没有应答者时默认为 `unavailable`;因此卸载 UI 即默认拒绝,不会留下通道。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,仅对「决定或委托」门禁使用 `prepend`。 + +`ApprovalRequest` 携带 agent、工具名、可选的 `callId`、原因和 signal。agent 同时路由提示和审计事件。请求使用 `dsh-llm` 的 `CallId` 而不导入 `dsh-tools`,避免包循环。工具参数被省略,因为 UI 应答者附着在已渲染的调用上。 + +#### dsh-tools 中的 Ask 路由 + +`ToolRegistry.execute()` 在拒绝路径之前将 `ask` 发送到审批 seam。只有 `allowed-once` 才继续执行;拒绝、取消和通道不可用产生三种模型可见的不同原因。注册表按调用查找可选服务,因此缺失或未加载的服务默认拒绝,不会阻塞注册表 fiber。无 agent 的执行同样默认拒绝,因为无法路由或审计。 + +#### 每会话策略层 + +seam 拥有会话策略 `'ask' | 'never'`,遵循[沙箱 RFC](2026-07-06-sandbox.md) 中的切换契约。生效的会话或配置策略在应答者之前应用:`'never'` 在 `request()` 内部拒绝,而 `'ask'` 派发请求,无人应答时降级为 `unavailable`。系统提示词仅声明确定性的 `'never'`;叙述者报告切换,每个请求仍然收到其审计对。 + +#### ACP 应答者 + +ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/request_permission`,并将一次性 allow、reject 和 cancel 响应映射到 seam 词汇。未知选项永远不授权。外部 agent 和没有 `callId` 的请求通过 `next()` 委托;RPC 失败变为 `unavailable`。桥应答请求但不决定哪些调用需要审批。 + +应答者通过 [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 描述的桥反向映射归属 seam 进行路由,实现了[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) 所要求的每会话权限归属。 + +#### 审计,以及模型看到什么 + +`approval/asked` 和 `approval/decided` 是持久的仅日志事件。模型只看到发起方记录的 `tool/result`。每个被接受的请求追加一条匹配的决策,包括取消和被包含的应答者失败。 + +#### 实体与依赖 + +`dsh-user-approval` 拥有固定的派发与审计机制;`dsh-tools` 发起请求,`dsh-acp` 应答。可替换的应答者作为监听器留在其通道拥有者插件中,因此三包能力拆分只会增加一个空的实现层。沙箱执行器仍然只负责传输,静态能力授权与交互式审批保持分离。 + +### 测试 + +- **单元/集成测试:** 覆盖先到先得的委托、fail-closed 默认值、格式错误和抛异常的应答者、取消竞争与迟到应答丢弃、观察者失败下的审计配对、不可绕过的 `'never'`、不同的工具拒绝原因,以及 ACP 每会话路由/结果映射。 +- **快照测试:** 通过沙箱升级的两个分支编排权限应答并固定 `'never'` 提示词加策略切换通知。没有组合应答者时钩子产生的 ask 仍作为 fail-closed 拒绝被覆盖。 + +## 延后 + +- **`allow_always` 授权存储**——兑现持久授权意味着设计存储、范围标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只宣告一次性选项([沙箱 RFC](2026-07-06-sandbox.md) § 升级记录了开放的范围问题)。 +- **有组合应答者时录制的钩子产生的 ask**——升级录制了人类提示的协议格式(wire format),而当前钩子 fixture(测试前置数据)固定的是无服务拒绝;它们组合的生产者/应答者路径仍由单元测试覆盖。 +- **将子 agent 的审批路由到父会话**——`subagent-acp` 的子端自动应答自己的 `permission` 请求;将它们呈现给父端编辑器是独立的设计。 + +## 曾考虑的替代方案 + +- **单个注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——白名单预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时默认拒绝和 HMR(热模块替换) dispose(资源释放);seam 的 JSDoc 用约定固定单决策槽语义,而非发明一个提供方注册表。 +- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会把发起 ask 的策略硬编码到 UI 插件中,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时机),且让钩子产生的 `ask` 决策没有共享机制。 +- **通用用户交互 seam(`ctx.userInteraction`)**:否决作为审批机制。两者共享骨架(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时默认拒绝、以及审计事件。因此审批不走已发布的 `packages/ui/user-interaction` / `ask_user_question` 引出路径——引出表单不是权限提示,自由文本应答不是封闭结果;如果两者未来趋同,共享提供方管道仍然开放。 +- **在 `dsh-tools` 中静态可选注入**:否决。vendor 的 cordis `Inject` 类型没有可选标志——对象形式将服务名映射到拦截配置,声明的 inject 会阻塞 fiber。`ctx.get('approval')` 是文档化的机会性消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测),按调用读取存在性,在 HMR 下无需额外机制即可正确降级。 +- **能力 seam 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seam(bash-local vs bash-sandbox)。这里服务体是固定机制,可变部分是留在各自通道拥有者中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。 +- **现在就提供 `allow_always`**:否决。协议可以表达它,但兑现它意味着设计授权存储、范围标识和撤销(§ 延后)。宣告一个 harness 无法兑现的选项只会制造注定失败的授权。 + +## 后果 + +- 只有 `allowed-once` 才会派发被询问的操作;缺失、拒绝、取消或应答失败的路径均拒绝。 +- 会话归属路由提示、策略和审计事件,不跨越编辑器会话。 +- 被接受的请求追加一对持久审计事件;模型只看到最终的工具结果。 +- 没有加载该服务的部署不会发出审批提示或审计事件,并在工具边界拒绝每个 `ask`。 + +代价与已接受的局限: + +- **两个急于决策的应答者争抢同一个槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者——通过约定缓解(每个部署一个终端应答者;仅对「决定或委托」门禁使用 `prepend`),而非事件总线不具备的优先级机制。 +- **生产环境的验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,以及沙箱升级通过其自身门禁——协议格式录制在沙箱示例的快照套件中,因此 seam 的真实覆盖率就是这一种组合,直到更多部署组合它。 +- **归属以 `Agent` 对象同一性为键。** 应答者通过桥现有的 WeakMap 解析会话;当前所有路径在 loop 和各 seam 之间传递同一个对象,但未来如果某个边界克隆或代理了 agent,桥会委托并默认拒绝——安全但静默无 UI——届时需要改用 session-id 匹配。 + +## FAQ + +- **在完全没有应答者的部署中(无头模式、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` 子端的自动应答是独立的;将子端的 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` 沿用了它;[钩子桥 RFC](2026-06-30-hook-bridges.md) 发布了 `permissionDecision: ask`,即第一个生产者。 +- [拦截 seam RFC](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。 +- [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)——应答者路由所经过的 `WeakMap<Agent, sessionId>` 归属 seam;[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。 +- 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。 diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml new file mode 100644 index 0000000000..352331b13d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-explicit-tool-order.md: 9d94496ffdcbc4c7df820581b02e3e075ec1c0be +2026-07-06-explicit-tool-order.zh.md: fa3a5bbf83115c25f87471b8a3847b9347d42934 diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index c56e1eb0ba..9d94496ffd 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -1,5 +1,7 @@ # RFC: Explicit model-facing tool order +English | [中文](2026-07-06-explicit-tool-order.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md new file mode 100644 index 0000000000..fa3a5bbf83 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md @@ -0,0 +1,50 @@ +# RFC:显式的模型侧工具顺序 + +Status: implemented + +[English](2026-07-06-explicit-tool-order.md) | 中文 + +## 问题 + +模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于彼此独立的插件在并发模块加载时的竞态。这一竞态导致 CI 和快照录制中产生不同的请求头。由于顺序影响请求字节、缓存和持久化的头部,需要一个显式的确定性策略。 + +## 决策 + +系统提示词组装拥有模型侧工具的权威顺序,正如它已经拥有 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 从这个确定性列表出发;未被改变的顺序随后流入请求头、冻结请求和重建检查,无需循环特有的排序逻辑。 + +范围刻意收窄:本 RFC 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍可添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已要求监听器具有确定性(可重建性不变式会捕获在构建与回放之间表现不一致的监听器)。 + +配置传递沿用 `persona` 的先例,`toolOrder` 与它并列:应用配置(`dsh-stdio-demo`、`dsh-acp-demo`)接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节是关键的:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链上的每个 schema 都将默认值强制为 `undefined`。 + +## 曾考虑的替代方案 + +- **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 不稳定),评审中不可见。 +- **插件依赖图的线性化**:该关系是偏序的,独立的工具插件之间不可比较;上述不稳定发生时偏序已完全满足。 +- **每个插件在工具贡献上设 `weight`**:将顺序分散到各插件中,仍需一个无人拥有的全局编号约定(section 的 `order` 分段已经展示了这种协调成本需要手工承担)。 +- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个被组装之外的更多消费方使用的成员存储;排序是 prompt 组合的关注点,而组装已经拥有 section 的组合策略。 +- **`LlmService` 配置 + 循环在记录头部前调用的 `orderTools()` 方法**:可行,但仅为在远处应用策略就增加了一个公开服务方法和一处循环改动;每个未来的请求组合者都必须记得调用。在列表诞生处规范化使无序列表不可表示,且零新增接口。 +- **在 `llm.stream()` 内部规范化**:在头部事件记录之后才运行(不稳定仍存在),且需要重建深度冻结的信封,静默地解除了重建不变式。 +- **穷举列表(无 rest 条目)**:每个新加载的工具插件都会导致启动失败;强制的 rest 条目使未列出的工具保持确定性,且其位置是显式的。 +- **启动时校验(`dsh-app-boot` 在 `loader.await()` 之后调用 `SystemPrompt.assertToolOrderSatisfied()`)**:能将配置错误变为启动死亡而非首轮失败,但需要一个公开服务方法加上通用启动胶水对单一服务的结构耦合,且无论如何不能替代组装时检查(嵌入式调用者从不运行 app boot;注册在 boot 之后仍会变化)。也没有现成事件可以承载该检查:Cordis v4 没有 ready 类事件,`loader/entry-init`/`internal/status` 在加载中途触发(与工具注册竞态——正是本 RFC 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设一个执行点被判定值得接受较晚的失败时刻。 + +## 后果 + +- 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转在结构上被消除,默认为字典序。 +- 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序出发;提供方注册顺序在该协作 seam 之前的任何地方都不可观测。 +- 步骤之间的纯工具重排只能表示为 `request/header` 的 `'fallback'` 快照(基于名称键的 `ToolsDelta` 无法表达它);在稳定的权威顺序下,这种重排在实践中不再发生,因此 fallback 路径仅作为安全阀保留。 +- `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 转发链传递,因此部署时在 app 配置中与 `persona` 并列设置;`dsh-llm` 和 agent loop 不受影响。 +- `toolOrder` 中拼写错误或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:循环在轮次内组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带消息,`agent/error` 镜像它,不开启步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(与仓库规则一致:显式配置引用不得被静默忽略——执行点在组装处,因为不存在更早的通用时刻)。 +- 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的列出名称相同。这防止哨兵值变成歧义的真实工具,并保持「从不丢弃工具」的排序契约。 + +## 测试 + +系统提示词测试覆盖字典序默认顺序、列出/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。循环测试固定跨注册排列的已记录和已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结请求,以及在未知配置名称下的平衡轮次失败(无步骤、无头部、无适配器调用)。快照回放仅在固定的 `text-turn` 头部中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。 diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml new file mode 100644 index 0000000000..c36f317c2f --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-07-mcp-client-plugin.md: 7706190257b54730532e4aa46cc9c47453c59871 +2026-07-07-mcp-client-plugin.zh.md: 4d2ea8532afbf6160a98020b8cc480e1bf683981 diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md index 1be5b225fe..7706190257 100644 --- a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -1,5 +1,7 @@ # RFC: MCP client plugin — connect to external MCP servers and bridge their tools +English | [中文](2026-07-07-mcp-client-plugin.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md new file mode 100644 index 0000000000..4d2ea8532a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -0,0 +1,214 @@ +# RFC:MCP 客户端插件——连接外部 MCP 服务器并桥接其工具 + +Status: implemented + +[English](2026-07-07-mcp-client-plugin.md) | 中文 + +## 问题 + +harness 此前无法消费 MCP(Model 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()`")。基础设施已就绪,缺的是桥接插件。 + +## 决策 + +### 包 + +单个包 `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 三包拆分:可预见范围内不会有第二种 MCP 客户端实现,且约定是「不要预防性拆分」(见[能力 seam RFC](../../implemented/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 客户端(不含服务器端——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`:远端名称是不可信输入,跨部署不唯一(同一服务器的 prod 和 staging 实例报告相同名称),且可能在服务器升级时变化——这些都不得静默地重命名模型可见工具。多个活跃实例使用相同 `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 和 description 原样透传(不做 `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 没有图片内容块类型;见 [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md))。 + - `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`)。 +3. 取消:`exec.signal`(来自 agent loop 的 cancel)透传给 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 服务器端(向外部 MCP 客户端暴露 harness 工具) + +推迟。ACP 桥接已将 harness 暴露为 agent 服务器。再加一层 MCP 服务器会用不同协议重复这一功能,而用户的首要需求是消费外部工具,而非暴露自身工具。 + +### 能力 seam 三包拆分(接口 / 实现 / 消费方) + +否决。可预见范围内不会有替代的 MCP 客户端实现:MCP 只有一个协议、一个 SDK。约定是「在第二种实现出现之前不要预防性拆分」。 + +### 指数退避自动重连 + +v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,未来可作为 `reconnect: boolean` 配置项加入。 + +### 桥接 Resources 和 Prompts + +推迟。Resources 需要 harness 侧的机制来决定何时注入内容(系统提示词?按需?模型触发?)。Prompts 需要 harness 目前缺少的「prompt 模板」概念。两者都需要独立设计;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 块会静默丢失块间边界——这是正确性 bug。所有现有工具返回单个 TextBlock;MCP 桥接遵循同样做法。 + +## 测试 + +覆盖按层级命名;每个行为放在能表达它的最低成本层级。 + +- **单元测试**(`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`,无需密钥):使用仓库内 fixture 服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem` 通过 stdio 运行真实 MCP 协议,以及通过进程内 `StreamableHTTPServerTransport` 服务器运行 Streamable HTTP——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose(资源释放)。 +- **快照**:刻意不做。MCP 工具不引入新的 transcript 渲染面——它们注册为原始 `ToolDefinition`,通过 ACP 桥接的通用卡片回退渲染,而桥接的单元测试套件已固定了该行为(`packages/ui/acp/tests/stream-update.spec.ts`)。将 MCP 服务器加入快照示例的 `cordis.yml` 会改变已固定的 `text-turn` 系统提示词 fixture(迫使每条录制的 golden 都需要带密钥重新录制),并使每次回放依赖于 spawn 一个外部 MCP 服务器进程——而新增的渲染行为为零。如果后续变更为 MCP 工具引入专属的渲染意图,该变更届时自行命名其快照覆盖。 + +## 后果 + +- 每个 MCP 服务器只需一条 `cordis.yml` 条目即完成集成:`serverName: filesystem` 加一条 stdio 命令(或一个 Streamable HTTP URL),就能把 `mcp__filesystem__read_file` 放入模型的工具列表,可调用,协议层使用原始的 `read_file`。 +- 公开名称是会话历史与权限/配置界面的一部分;命名算法是由测试固定的 v1 契约,发布后修改它是破坏性变更。 +- `mcp__<serverName>__` 限定符在每个名称上消耗 token。已接受:description 和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配形状(`mcp__*`、`mcp__github__*`)。 +- **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。 +- **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的 description、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的责任。 +- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号可能卡住 dispose。Cordis fiber 的 dispose 有有界静默期;卡住的传输层最终会在框架层面超时。 +- 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置项作为未来工作保持开放。 diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml new file mode 100644 index 0000000000..fddf23cccb --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-07-session-prefix.md: ffa0fecb86e84ed64d45b24e1b6943d421757fb2 +2026-07-07-session-prefix.zh.md: 292c74fac75d8f2c29628fc5e90c72dadcaf4fdb diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index f0d458368a..ffa0fecb86 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -1,5 +1,7 @@ # RFC: The session prefix — request-only messages in front of the derived history +English | [中文](2026-07-07-session-prefix.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md new file mode 100644 index 0000000000..292c74fac7 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md @@ -0,0 +1,44 @@ +# RFC:会话前缀——置于派生历史之前的仅请求消息 + +Status: implemented + +[English](2026-07-07-session-prefix.md) | 中文 + +## 问题 + +插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在这个 seam 出现之前,harness 只提供两个归属位置,但对这类内容来说两个都不对。系统提示词是一个渲染后的单字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对对话消息与系统文本的权重处理不同。持久化历史(`agent.inject()`、会话开始时的 `context/message`)会让开场内容变成永久记录:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会把它以陈旧状态烘焙进去,resume 无法刷新它——一份在会话诞生时捕获的目录会比它所描述的世界活得更久。 + +显而易见的第三个选项——让插件在请求发出时编辑 `messages`——被[可重建请求 RFC](../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()]`,在协议格式上产生注册顺序)。循环([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,延迟到该实例首次 `agent/pre-step` 之前;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发送的每个请求中置于**整个**派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 + +三个属性承载了这一设计: + +- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 RFC 已经为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + 边界派生`;未记录的前缀无法到达协议格式。 +- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存在构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 是一个新实例:它重新组合,任何漂移都可归因地落在 `'resume'` header 快照上。这就是该 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——[拦截 seam RFC](2026-06-30-interception-seams.md)),每条都是一次性持久化的 `context/message`,之后被前缀缓存覆盖。 +- **在压力门禁之前组合。** 组合先于实例的首次 `agent/pre-step`,且 seam 将组合值传递下去:`agent/pre-step` 携带 `sessionPrefix` 参数,`CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` 将其计入 token 压力估算——如果门禁读取的是上一个实例折叠后的前缀,那么在一个贡献者增长了的 resume 或 fork 实例的首步上会低估压力,跳过压缩并发出超窗口的首请求。组合过程中如果 cancel/dispose 落入 waterfall 内部,组合结果被丢弃、永不缓存:一个感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活跃 signal 下重新组合。 + +由于组合在边界快照之前运行,组合监听器的会话追加会加入**当前**请求的派生历史。压缩在结构上无法触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。 + +## 测试 + +[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了无 header 增量时的组合一次复用、前置顺序、空前缀省略、不可变性,以及组合先于 pre-step;[取消测试](../../../../packages/core/agent-loop/tests/cancel.spec.ts)固定了丢弃与重新组合。会话编解码器、不变式和压缩测试覆盖 header 往返、请求重建和前缀感知的压力计算。快照规范化保留前缀计数,而[固定 header 场景](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md)拥有内容,默认示例保持无前缀。不需要前缀专属的 e2e 测试,因为该 seam 是确定性的且与提供方无关;带密钥的[请求缓存 e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) 覆盖了其缓存经济性。 + +## 曾考虑的替代方案 + +- **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:每个请求触发一次 waterfall,贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入静默漂移——除非每步记录一个 header 增量,否则没有东西将其锚定到日志——而 `after` 槽位位于不断增长的历史之后,其 token 在每个请求中重新支付,且其后的所有内容不可缓存。与各替代方案对比衡量,当前每种更新模式都能由持久追加更廉价地服务(支付一次,此后缓存读取),唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。 +- **系统提示词分区**(`system-prompt/assemble`):对此类内容否决。组装渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带 header 增量),而开场内容需要的是按实例冻结的语义。 +- **持久化历史开场**(会话开始时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处回放、可被压缩、跨 resume 陈旧。 +- **按轮次而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制每次变化产生一个 header 增量,且它每次触发都会破坏提供方缓存;合理的刷新点是实例边界,`'resume'` 快照已经在那里可归因地记录漂移。 +- **在首请求时延迟组合,让压缩读取折叠后的 header**(首次合入时的形态):评审中被取代。折叠值只从实例的第二个请求起才与活跃前缀匹配,因此在 resume/fork 实例的首步上,压力门禁读取的是**上一个**实例的前缀,可能低估压力。在首次 pre-step 之前组合并通过 seam 传递活跃值,使估算在每一步都精确。 +- **承载前缀的专用会话事件**:否决。header 事件在设计上就是请求的非历史记录;第二个事件会成为同一事实的第二个归属,以及又一个需要保持完整的编解码器。 + +## 后果 + +- `agent/pre-step` 和 `CompactService.compactIfNeeded` 携带 `sessionPrefix` 参数:每个 pre-step 监听器和压缩后端都能看到真实的每实例值(所有仓库内实现在同一个变更中更新,遵循预发布立场)。 +- 内容在会话中途变化的贡献者不会被重新读取,直到下一个实例——这是设计意图。需要会话中途目录更新的部署应将变更通知路由到仅追加历史通道,支付一条持久化 `context/message`。 +- 被放弃的 `after` 槽位使请求尾部没有仅请求通道;仓库中没有任何东西需要它,且加回它会重新引入该设计旨在避免的每步重复支付成本。 +- `request/header-delta` 的 `messagePrefix` 分支(整数组替换,空数组编码向缺失的过渡)为编解码器完备性而存在;循环从不行使它,因为缓存的前缀在实例内不可变。 +- 空组合是规范的缺失状态:无贡献者的部署不记录额外 header 字节,其请求就是裸派生。 diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml new file mode 100644 index 0000000000..03817c1073 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-08-repeat-tool-guard.md: 04d5d077a42b54ca7dc04a1efc9ea2f4034b642b +2026-07-08-repeat-tool-guard.zh.md: e20bd06a6902f9fadb77a90e719aaf703d7067cc diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 324aa37256..04d5d077a4 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -1,5 +1,7 @@ # RFC: Repeat-tool-call guard plugin +English | [中文](2026-07-08-repeat-tool-guard.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md new file mode 100644 index 0000000000..e20bd06a69 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md @@ -0,0 +1,76 @@ +# RFC:重复工具调用守卫插件 + +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 RFC](2026-06-30-interception-seams.md) 赋予 `tools/post-execute` 一种正式途径,可以在已完成的调用上附加面向模型的上下文;循环缓冲并注入该上下文,保持调用/结果的邻接关系;注入的上下文是一条已记录的 `context/message`——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺的只是插件本身。 + +## 决策 + +守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻塞或改写调用;模型自行决定是否换一种方式重试或结束。 + +该插件为 `@deepseek-ai/dsh-repeat-tool-guard`,位于 `packages/guard/repeat-tool-guard/`,开辟 `guard/` 分组用于循环卫生插件(单包分组有先例:[todo-write RFC](2026-06-29-todo-write-tool.md) 发布了 `todo/tool-todo`)。它注册三个监听器,所有状态保存在以 `AgentId` 为键的插件局部 map 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent 的调用(subagent 运行在同一 context 上),因此按 agent 分键是正确性要求,而非锦上添花。 + +- **`tools/post-execute`(waterfall)**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要 pending map 仅因其 `tool_call`/`tool_result` 钩子是独立事件)。它始终通过 `next()` 委托,当命中阈值时,将提醒折叠到下游决策的 `additionalContext` 上——这正是[钩子桥接](2026-06-30-hook-bridges.md)已在使用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非 `tools/pre-execute`,是因为 post-execute 也会为被拒绝的调用触发(`ToolRegistry.execute` 将 deny 路由到同一流水线),而模型反复锤击一个被拒绝的调用恰恰是值得打破的循环。 +- **`agent/prompt-submit`(waterfall)**——纯重置钩子:通过 `next()` 委托,清除提交 agent 的链。用户介入改变了上下文;跨越介入的重复不是循环。 +- **`agent/status`(emit)**——在 `disposed` 时丢弃该 agent 的状态,限制 map 在 harness 生命周期内的增长。 + +### 检测语义 + +链的键为 `(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()` 的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的 `AgentId`。 + +### 提醒投递 + +提醒使用 `additionalContext` 并标注插件来源,保留原始 `tool/result`。首次阈值发出简短提示;后续阈值包含工具名、计数和有长度上限的参数预览,而比较仍使用完整的规范化字符串。已有的下游上下文在守卫的 source 下拼接,因为 `HookContext` 支持单一 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 执行、通配符转义、无效配置,以及下游阻塞或替换决策,达到逐文件 100% 覆盖率。 +- **快照测试:** keyless 的 `repeat-tool-guard` 场景发出五次相同的 `todo_write` 调用,将第三次的温和提醒和第五次的详细提醒固定在 ACP 输出和会话日志中。该插件在实时示例中加载,但在其他场景中保持静默。 +- **E2e:** 无;该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。 + +## 曾考虑的替代方案 + +- **将提醒追加到工具结果中**(`accept` 并替换 `content`——pi 扩展的机制,它修改结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的 `tool/result` 对工具实际返回的内容撒谎,而 `additionalContext` 正是为 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 生产者在同一次调用上附加上下文时,折叠在守卫的 `source` 下拼接;插件间的顺序遵循监听器注册顺序。该 seam 无法表示混合来源——这是继承自 `HookContext` 的限制,不属于本插件。 + +## 延后 + +- 上下文压缩(compaction)不重置链:压缩后的历史改变了模型所见,但重复风险通常在压缩后仍然存在。 +- 在高阈值升级为 `block` 未实现;`PostToolDecision` 已支持此选项,待证据出现后可启用。 +- subagent 的链按 agent 隔离;在出现具体需求之前不引入共享机制。 diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml new file mode 100644 index 0000000000..e667e7a029 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-08-self-referential-cordis-toolset.md: 62b97dc4bdbd0e0b5b1f67f77c763065c79964ed +2026-07-08-self-referential-cordis-toolset.zh.md: ce220d256dbb3d43514702e57e71728fdc82a788 diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 7ea5f4390e..62b97dc4bd 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -1,5 +1,7 @@ # RFC: The self-referential cordis toolset +English | [中文](2026-07-08-self-referential-cordis-toolset.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md new file mode 100644 index 0000000000..ce220d256d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -0,0 +1,82 @@ +# RFC:自引用 Cordis 工具集 + +Status: implemented + +[English](2026-07-08-self-referential-cordis-toolset.md) | 中文 + +## 问题 + +本 harness 中的一切都是 Cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。把这种能力交给模型值得探索——一个能审视并修改自身运行时的自引用 agent——但它同时引出三个正确性问题,而本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制。 + +第一,模型编写的注册必须在注册发生时就被校验:格式错误的工具 schema 必须在注册时失败,而非等到后续请求尝试将其组装进提示词时才暴露。第二,模型编写的代码需要调用它从未见过源码的服务 API——猜测方法签名,更糟的是猜测返回值形状,会耗费大量盲目试探步骤。第三,模型挂载的一切都必须完全可 dispose(资源释放):模型可以按需释放,宿主插件重载时普通的插件生命周期也能释放,否则长会话会积累遗留的监听器和工具。 + +## 决策 + +该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布——一个新的顶层 `packages/cordis/` 分组——并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作模型自身运行其中的活跃 Cordis 运行时:审视它、向其中挂载模型编写的插件、再将它们 dispose。 + +vm 隔离了意外的全局污染,上下文门面隐藏了框架内部实现。二者都不限制已暴露服务的权限:一个挂载可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能触及真实文件系统和网络服务。这是一个需要主动启用的开发工具,信任等级与 bash 等同,既不是安全边界,也不是产品默认配置。 + +### 三个工具 + +| 工具 | 契约 | +|---|---| +| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则返回全部段落)。从不修改状态。 | +| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数体);代码必须 `return` 一个 Cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新生成的 id(`dyn-1`、`dyn-2`、……)追踪。 | +| `cordis_unmount` | 按 id dispose 一个动态挂载,并等待 disposal 达到静止——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 | + +`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber,非活跃的 owner 会被标记)、`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`(挂载表:id、名称、状态、提供的服务、等待的服务)、`api`(来自生成目录的活跃服务签名及其引用的类型形状)、`events`(harness 事件及其分发模式和签名)。面向模型的工具描述携带模型在调用时所需的操作规则;[生成的工具目录](../../../tool-catalog.md)是其完整渲染。 + +### 沙箱语义 + +挂载代码作为异步函数体在一个新的 vm realm 中运行。其文档化的接口面将文件、网络、进程和定时器访问引导至 Cordis 服务,使挂载保持可审视和可 dispose。宿主 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 的 JSON,并在记录日志前校验 `ToolExecuteReturn` 形状。挂载的插件接收一个白名单上下文门面,而非原始或直通的 `Context`;框架管道和以 context 为值的返回会被拒绝。服务读取要求声明 `inject`,保持 Cordis 的激活和卸载语义。`ctx.tools.get` 仅暴露 schema 视图,使挂载代码无法绕过 `ToolRegistry.execute` 直接调用定义。 + +边界将无歧义的 JSON-Schema 形式规范化为 `SchemaSpec`,包括对象包装、`integer` 和可选字段。无效词汇会失败并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 错误和重复工具错误会包含相关源代码行或纠正性契约,但不叙述实现内部细节。 + +### 动态分组与挂载生命周期 + +所有动态挂载都是工具插件下方一个 `cordis-dynamic` 分组的子节点,因此普通的 fiber disposal 即可处理重载和卸载。挂载会等待 settlement;启动失败会在返回错误前 dispose 该 fiber。已 settle 但处于 pending 状态的挂载仍然可见,并列出其缺失的注入。`cordis_unmount` 等待挂载 fiber 的 disposal。 + +### 通过 provide/inject 实现跨挂载组合 + +挂载之间通过普通的 Cordis 服务语义相互关联,以各自的 id 作为生命周期句柄:挂载 A 调用 `ctx.provide('foo', value)`,挂载 B 声明 `inject: ['foo']` 并在 `foo` 存在的瞬间激活;如果 B 先挂载,它会保持 pending 状态并列出缺失的服务;卸载 A 会使 B 回到 pending(其注册被撤销),之后重新 provide 会通过一个新的沙箱门面重新运行 B 的 `apply`;重复 provide 会大声失败并指名拥有该服务的 fiber。一个 realm 注意事项:挂载提供的服务值是 vm realm 对象——从任何地方调用其方法都能工作,但消费方不得假设其上有宿主原型。 + +### 生成的 API 目录 + +`cordis_inspect` 从生成的目录而非重复的表格提供 API 和事件数据。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、事件模式、引用的类型声明和继承的上下文接口面。有歧义的类型名被省略,过大的声明被标记为截断。 + +新鲜度像所有生成产物一样受门禁保护:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此修改了公开签名的 JSDoc 变更在不重新生成模型所读目录的情况下无法发布。运行时,inspect 工具将目录与活跃运行时取交集而非直接转储:有目录条目的活跃服务渲染摘要 + 签名,没有目录条目的活跃服务(挂载提供的)渲染名称 + 所属 fiber,有目录条目但没有活跃提供方的服务简要列出,引用的类型形状随后附上。 + +### 配置、渲染与可观测性 + +该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名称、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。 + +「模型可见 ⟺ 已记录」成立,且不引入新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它),而挂载引起的工具集变化则由循环在 schema 在步骤间变化时已有的请求头 delta 日志记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复持久化的会话会重建对话但不会重新挂载插件。 + +## 曾考虑的替代方案 + +**用结构化的逐能力注册工具替代 `cordis_mount`。** 最诱人的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`、……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景省去插件样板——不足以抵偿其代价,而单一的挂载原语能一次性覆盖所有能力。 + +| 维度 | 结构化逐能力工具 | 单一 `cordis_mount` | +|---|---|---| +| Schema 正确性 | `parameters` 仍是模型编写的 JSON 对象,需要 SchemaSpec 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误 | +| 代码字段 | `execute` 体仍是 vm 中模型编写的 JS;realm 和服务调用正确性问题不变 | 一个沙箱、一条规范化路径、一道受守护的注册 | +| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇(一个 Cordis 插件)覆盖当前和未来的所有效果 | +| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通 Cordis 语义 | +| 可审视性 | 注册的东西在插件列表中无法作为插件展示 | 模型挂载的东西正是 `cordis_inspect` 渲染的东西 | +| 模型易用性 | 对最常见的单一场景有优势(无插件样板) | 通过挂载描述中的规范示例加上教导正确做法的边界错误来缓解 | + +因此,正确性投入放在能一次性覆盖所有能力的地方:通过 `cordis_inspect` 暴露的生成 API 目录,以及沙箱边界校验——其错误消息教导正确的调用方式。结构化注册工具日后仍可作为语法糖添加,合成挂载代码即可;本设计不排斥它。 + +**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一张手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节,且没有门禁检测这种漂移;而生成产物的新鲜度由与文档使用同一 AST 的检查来保证。 + +**新增 `cordis/mount` 会话事件。** 记录每次挂载(源码、名称)的持久溯源事件有明确先例(`hook/invoked`、`compact/start`)。v1 中否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为请求头 delta 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源与工具调用分离,日后仍可添加。 + +**加固的 / 能力受限的沙箱。** 拦截 Node 内置模块并向挂载代码提供白名单门面而非原始 context,可能暗示意图是为安全而沙箱化。明确声明并非如此:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 Cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未守护的 context 逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)对一个开发/主动启用的工具集来说超出范围,且与其核心目标——将活跃运行时交给模型——相悖。 + +## 后果 + +该工具集是刻意需要主动启用的,具有完全权限的 `ctx`,因此部署方采用它的意识程度与采用 bash 工具相同。以下事实由工具描述直接告知模型:waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。 diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml b/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml new file mode 100644 index 0000000000..eef8b3d86c --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-10-session-query-service.md: 8b742ac19fea21d8404f5f44aa64f8c3cb3efccc +2026-07-10-session-query-service.zh.md: 43175b05d82ad758a16e516f3fd8b7b650f9d762 diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index 7e13256669..8b742ac19f 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -1,5 +1,7 @@ # RFC: Exact session query service +English | [中文](2026-07-10-session-query-service.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md new file mode 100644 index 0000000000..43175b05d8 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md @@ -0,0 +1,43 @@ +# RFC:精确会话查询服务 + +Status: implemented + +[English](2026-07-10-session-query-service.md) | 中文 + +## 问题 + +会话历史存在于两处:当前的 `SessionStore` 对象和可选的持久化后端。需要精确检查的消费方如果不借助统一服务,就得各自重复实现活跃/持久化优先级、持久化生命周期处理、原始事件 surface 分类和防御性克隆。检查点之间持久状态可能落后于活跃日志,因此单靠持久化并不是可信的当前数据源。 + +全文搜索与此相关但规模大得多。在真正的后端出现之前就设计提供方注册、抽取、同步、失效、排序和游标契约,会产生两个投机性的状态机:一个在接口服务中,另一个在最终的数据库包中。 + +## 决策 + +`@deepseek-ai/dsh-session-query` 拥有 `ctx.sessionQuery`:一个面向单一逻辑语料库的小型可信精确读取服务。它暴露 `listSessions()`、`listEvents(sessionId)` 和有界的 `readEvent(request)`。它不暴露过滤器、血缘/溯源遍历、文本抽取器、搜索请求、提供方注册或派生索引同步。 + +该服务动态观察可选的 `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。`listEvents()` 利用该结果将每个原始事件分类为 `current`、`shadowed` 或 `log-only`,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。 + +`readEvent()` 返回完整的目标事件以及按连续 seq 排列的原始邻居。`before` 和 `after` 默认为零,各自受 `readWindowMax`(默认 50)约束。结果携带克隆的 `SessionHeader` 而非来源可用性记录,因为判断活跃目标的 persisted 标志会违反「活跃精确读取不依赖持久化健康状态」这一保证。 + +## 安全边界 + +该服务是上下文范围内的可信基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话作用域。本阶段不添加面向模型的工具,也不改变 transcript(文本记录)或快照 surface。 + +## 曾考虑的替代方案 + +- **让每个消费方自行实现逻辑语料库解析**:否决。来源优先级、冲突处理、可选服务生命周期、克隆和 surface 分类是共享的正确性规则。 +- **只查询持久化**:否决。检查点之间持久化可能落后于当前活跃日志。 +- **缓存持久化元数据并监听写入/删除**:否决。精确读取可以直接询问权威来源,而缓存失效在规模尚未要求之前就引入了生命周期和并发状态。 +- **现在就定义提供方无关的搜索协议**:否决。目前没有提供方消费它。第一个 SQLite FTS 包应自行拥有一个协调/事务状态机;只有当第二个实现证明了边界时,才提取更小的共享 seam。 +- **在第一阶段就包含血缘、溯源和通用过滤器**:否决。当前没有消费方需要它们,且规范日志足以在有证据时再行添加。 + +## 后果 + +第一阶段只有一个来源解析状态变量:当前挂载的持久化服务。没有提供方队列、指纹、抽取器注册表、观察代次或派生索引更新。精确读取在纯活跃部署中仍然可用,在持久化存在时具有确定性。 + +跨语料库列举和持久化精确读取每次调用都执行后端 I/O。这是有意为之:正确性来自当前权威状态,面向规模的搜索属于第二阶段的数据库。全文搜索在该包定义并实现其完整契约之前不可用。 diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml new file mode 100644 index 0000000000..ad328eaaaa --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-subagent-persona-tool-filter-and-depth.md: 368f3a3592c5e241bb9357d4d4ce32e175c3de45 +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6c1ce8ac08fe3d37c400d489808e592570ebd6c7 diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 3c2e17aebd..368f3a3592 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -1,5 +1,7 @@ # RFC: Configure subagent persona, tool visibility, and depth +English | [中文](2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md new file mode 100644 index 0000000000..6c1ce8ac08 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -0,0 +1,94 @@ +# RFC:配置 subagent 的 persona、工具可见性与深度 + +Status: implemented + +[English](2026-07-12-subagent-persona-tool-filter-and-depth.md) | 中文 + +## 问题 + +一个可复用的 subagent 提供方解决的是「如何运行子 agent」的问题,但不同的委派工具需要不同的子 agent 行为。某个部署可能需要一个评审者 persona、一组仅限研究的工具集,或一个硬性递归上限,而不必为每种组合都创建新的提供方。 + +这些控制影响子 agent 的第一次模型请求,因此不能在子 agent 可见之后才安装。它们还需要提供方诚实地声明支持:ACP 后端不能静默接受一个仅适用于进程内的工具过滤器,而过滤器也不应在所有插件运行于同一可信进程时被描述为安全边界。 + +## 决策 + +subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持,服务在启动运行前拒绝不支持的请求,而进程内提供方在子 agent 尚未发布时安装所请求的组合。 + +这些控制回答不同的问题: + +| 控制 | 问题 | 结果 | +|---|---|---| +| `persona` | 哪些角色指令替换该子 agent 的部署 persona? | 一个子 agent 局部的 prompt 段落遮蔽 `deployment:persona` | +| `toolFilter` | 哪些部署全局工具进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 | +| `maxDepth` | 这棵委派树最深可以长到多少层? | 当子 agent 深度超过绝对上限时,启动请求被拒绝 | + +`dsh-tool-subagent` 将这些控制作为插件配置暴露,并将它们复制到每个创建的请求中。直接调用 `SubagentService` 的调用方可以按请求选择。提供方能力描述符仍然是后端能否兑现各字段的真源。 + +### Persona 是有作用域的遮蔽 + +persona 控制改变一个子 agent 而不改变部署级别的 prompt 组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者胜出解析规则仅在该子 agent 的组装中替换全局段落。 + +其值具有与部署 persona 相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局 persona。父 agent 和兄弟 agent 的 persona 永远不会进入子 agent 的扁平作用域。 + +这使用的是正常的系统提示词注册机制,而非第二条 persona 通道。因此第一次 prompt 看到的命名贡献与后续 prompt 和 prompt 检查工具看到的相同。 + +### 工具过滤是一条实时的全局视图规则 + +工具过滤器同时控制能力可见性与可执行查找。进程内提供方在发布前于子 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 的深度为其父 agent 经验证的深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。 + +每个公开入口都验证值域,而不依赖单一的面向模型的配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的已存储父深度以及推导溢出都会被拒绝。省略上限则该机制不约束深度。 + +部署可以组合深度与过滤。例如,可以在深度一时保留委派工具可见但设置 `maxDepth: 1`,或在子 agent 中完全 deny 委派工具。两种选择都不改变提供方的对话历史行为。 + +### 能力门控保持提供方诚实 + +能力将请求的特性与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中的每个字段。 + +这使得 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择一个不支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。 + +### 未发布的设置使第一次请求正确 + +所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装 persona、工具限制和结构化输出贡献。只有设置成功后,创建才会发布会话和 agent 并允许驱动器启动。 + +设置失败会回滚私有的子 agent。没有观察者能获取到一个「第一次 prompt 使用了部署 persona 或未过滤工具集、后续 prompt 才使用请求配置」的子 agent。 + +## 可见性不是授权 + +这些控制组合的是可信的同进程行为;它们不授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 是父 agent 的子集,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。 + +特别地,子 agent 局部工具在全局过滤之后添加,可能不在父 agent 的视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后续全局工具。这些是有意的实时组合语义,而非不可提权保证。 + +安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父集合子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签都不在本特性范围内。 + +## 曾考虑的替代方案 + +**为每个 persona 或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍然需要递归机制。提供方的职责仍然是执行传输;请求承载每个子 agent 的组合。 + +**复制父 agent 的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析的视图还会冻结动态全局注册,并在未完整定义任一契约的情况下混淆组合与授权。 + +**在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册一律不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为实时注册表谓词,并直接记录 allow 与 deny 的行为。 + +**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个 prompt 声称不存在的工具。改为由一个解析器同时管控呈现与执行。 + +**仅用工具过滤来阻止递归。** 移除委派工具有用但依赖特定提供方,且无法保护直接的服务调用方或替代委派工具。绝对深度是一个独立的结构性约束。 + +## 后果 + +贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始前失败,未发布的设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 + +代价是部署方必须理解实时 allow/deny 行为以及可见性与授权的区别。提供方作者必须准确声明每个支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可提权问题。 diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml new file mode 100644 index 0000000000..31c5a1b2e0 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-doc-sync-enforcement.md: 44ea84daddcae73ce07b0a8240f83ee9945e449d +2026-06-11-doc-sync-enforcement.zh.md: e7abe2d87fd97211f653b63ddb8818077532af48 diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md index 8fd37a1656..44ea84dadd 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md @@ -1,5 +1,7 @@ # RFC: Doc-sync enforcement +English | [中文](2026-06-11-doc-sync-enforcement.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md new file mode 100644 index 0000000000..e7abe2d87f --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md @@ -0,0 +1,32 @@ +# RFC:Doc-sync 强制 + +Status: implemented + +[English](2026-06-11-doc-sync-enforcement.md) | 中文 + +## 问题 + +AGENTS.md 承诺文档与代码严格同步,但这一承诺此前只靠肉眼验证。评审曾两次发现漂移:一次是实操手册(cookbook)示例与类型策略矛盾,一次是 README 引用了错误的 `registerAdapter` 调用。失去同步的文档比没有文档更糟;而本代码库主要由 agent 构建,agent 对门禁的遵从远比对行文的遵从可靠(机械质量门禁)。有两类文档漂移可以被机械检查:不再能编译的代码块,以及重复了 `interface Events` 声明的事件分类体系表。 + +## 决策 + +两道门禁,沿用既有的 `scripts/` 风格(tsx ESM,每个脚本一项职责): + +1. **`doc-typecheck`** 从 `README.md`、`docs/**` 和 `packages/*/README.md` 中提取所有 ` ```ts ` 围栏代码块,写入一个继承根 `tsconfig.json` 的临时项目,然后用 `tsc -b` 编译。临时项目复用源码的 `paths` 映射和根 project references,因此文档示例能看到源码,而 vendor 代码仍在其自身的 tsconfig 设置下被检查。刻意作为草图的代码块可以用显式的 ` ```ts ignore-check ` 信息字符串退出检查;脚本会报告退出比例,超过一半则失败,防止逃生口悄悄变成常态。 +2. **`verify-event-taxonomy`** 从 `packages/*/src` 的 `interface Events` 块中提取事件名,再从 `docs/architecture.md` 的分类体系表中提取事件名,断言两个集合完全一致。只校验、不生成:表格保留手写的 Mode/Purpose 列,只检查名称集合。(落地此门禁时发现了表格缺失的三个事件:`tools/change`、`llm/adapter-change`、`system-prompt/change`。)**已被取代**:[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)取代了此门禁及其 `architecture.md` 表格,改为完全生成的 `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` 及其 `verify-cordis-catalog` 新鲜度门禁。本文的其他门禁(`doc-typecheck` 以及下文修订的 `verify-md-wrap`)不受影响。 + +两者通过一个共享的 `doc-sync` package.json 脚本运行,lefthook pre-push 钩子和 CI 都调用它([机械质量门禁](2026-06-11-quality-gates.md):钩子和 CI 调用相同的脚本,因此门禁在推送前就在本地触发,而不仅仅在推送后)。它们在 `pnpm run typecheck` 之后运行,后者校验 doc-typecheck 所引用的 package/vendor 构建图。 + +**修订(2026-06-17):** 第三道门禁 **`verify-md-wrap`** 后来也被纳入 `doc-sync`。它用 `mdast-util-from-markdown` + GFM 解析范围内的每个 Markdown 文件(`README.md`、`docs/**`、`packages/*/README.md`,加上 `AGENTS.md` / `packages/AGENTS.md`),对任何跨越多行的 `paragraph` 节点报错,强制执行 docs/AGENTS.md 中「一个段落一个物理行」的写作规则。同样遵循只校验不生成的原则:它报告硬换行,从不重写,因此不会引入格式化噪音。`doc-sync` 现在包含三道门禁。 + +## 曾考虑的替代方案 + +- **API-extractor 黄金报告**([已推迟的提案](../../proposed/process/2026-06-11-api-extractor-reports.md)):有意推迟。对于评审者已经能看到源码 diff 的内部 monorepo 而言价值不高,且依赖笨重、配置繁琐。 +- **从源码生成分类体系表**而非校验名称:否决,机制比问题本身更重;表格保留手写的 Mode/Purpose 列,直到[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)完全取代了这项检查。 + +## 后果 + +- 可机械检查的文档漂移现在会让 pre-push 钩子和 CI 失败,而非等待评审者发现。这是「机械门禁优于行文约定」原则的一个实例。 +- 让文档代码片段可编译需要少量 stub import 或 `declare`;`ignore-check` 比例必须保持低位,否则门禁形同虚设(比例守卫强制执行这一点)。 +- 分类体系检查仅限名称:Mode 或 Purpose 列的错误仍需人工评审。 +- 如果这些包(package)将来对外发布,API 报告仍可重新考虑。 diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml new file mode 100644 index 0000000000..addb813047 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-quality-gates.md: 9862dc019dd6ff3b4639983821256395d0ee7b77 +2026-06-11-quality-gates.zh.md: 7a9dd7cead0e7a4964a44b650664ceb7ff570c7b diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 9f92791f4a..9862dc019d 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.md @@ -1,5 +1,7 @@ # RFC: Mechanical quality gates over prose guidelines +English | [中文](2026-06-11-quality-gates.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md new file mode 100644 index 0000000000..7a9dd7cead --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md @@ -0,0 +1,28 @@ +# RFC:以机械质量门禁取代行文约定 + +Status: implemented + +[English](2026-06-11-quality-gates.md) | 中文 + +## 问题 + +本代码库主要由 coding agent 开发。相比行文约定,agent 遵守强制门禁的可靠性远高得多;而当劳动由 agent 完成时,「工作量大」不构成成本论据。早期证据:未通过类型检查的测试被提交了(vitest 不做类型检查),只在评审时才被发现。 + +## 决策 + +AGENTS.md 中的每一项承诺都对应一条退出码非零的命令,同时接入 git 钩子和 CI,两者调用相同的 package.json 脚本: + +- 最严格的 TypeScript(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录 no-emit `tsconfig.json` 在 CI 中进行类型检查,而 package/vendor 代码保持在各自 project-reference 边界之后。 +- ESLint strict-type-checked + @stylistic(作为强制执行的项目风格),包括文件内重复逻辑检查;vendor 代码排除在外。 +- jscpd 检测 package 生产 TypeScript 和仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 +- `packages/*/*/src` 的逐文件 100% 覆盖率(v8);不可达的防御性守卫保留 `/* v8 ignore */ ` 并注明理由,而非删除。 +- knip(死代码/依赖)、publint(包正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 +- lefthook pre-commit(lint 暂存文件、类型检查、vendor manifest 守卫)和 pre-push(测试、hygiene);CI 在 Node 22.19/24/26 上运行完整矩阵,外加一个端到端驱动 echo-agent 的演示冒烟测试。 + +## 后果 + +- 约定在 agent 更替后仍然存续;违规在本地快速失败。 +- 门禁本身也是需要维护的代码;配置变更与其他变更一样需要评审。 +- 100% 覆盖率的压力可能催生无断言的测试——变异测试是计划中的对冲手段(见[变异测试提案](../../proposed/testing/2026-06-11-mutation-testing.md))。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml new file mode 100644 index 0000000000..d7062a1c20 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-tsdown-over-dumble.md: c16ac691a9452d952303cf73b40693447d25015c +2026-06-11-tsdown-over-dumble.zh.md: 637a15a49bf22a0dd006039dd1ae2d0ea8120424 diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md index 1e63cef940..c16ac691a9 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md @@ -1,5 +1,7 @@ # RFC: tsdown for JS bundling instead of dumble +English | [中文](2026-06-11-tsdown-over-dumble.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md new file mode 100644 index 0000000000..637a15a49b --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md @@ -0,0 +1,30 @@ +# RFC:用 tsdown 替代 dumble 进行 JS 打包 + +Status: implemented + +[English](2026-06-11-tsdown-over-dumble.md) | 中文 + +## 问题 + +初始构建使用 **dumble**——cordiverse 的零配置 esbuild 包装层,上游 Cordis 本身也用它构建——与 vendor 包的约定最大程度对齐(它读取每个 package.json,从 `exports` 字段推断入口和格式)。但 dumble 作为本仓库的承重工具是一个隐患:v0.2.x,每周约 530 次 npm 下载,实质上只有一位维护者,而且由于它没有 workspace 模式,我们不得不通过一个自定义编排脚本(`scripts/build.ts`)来调用它。 + +目前构建产物只对 `pnpm run build` + publint 有意义(尚无包发布;开发/测试/演示通过 tsx 直接运行未打包的源码),因此切换成本现在最低,一旦包开始发布就只会更高。 + +## 决策 + +用 **tsdown**(基于 rolldown,每周约 250 万次下载,VoidZero 支持,活跃发布)替代 dumble: + +- 根目录 `tsdown.config.ts`,配置 `workspace: ['vendor/*', 'packages/*/*']`(显式 glob 将打包范围限定在 vendor Cordis 和 TypeScript 包树;`workspace: true` 还会发现示例 manifest 和不需要打包的 workspace 成员)。 +- 共享形态:入口 `lib/types/index.js`,`outDir: 'lib'`,ESM,`platform: node`,`target: es2024`,`fixedExtension: false`(对 `"type": "module"` 的包保持 `.js` 扩展名),`dts: false`(声明文件由 tsc -b 负责),`clean: false`(lib/ 同时存放 TSC 的 `lib/types` 中间产物树)。入口最初是 `src/index.ts`;[TSC 优先构建 RFC](2026-06-17-ts-build-config.md) 后来将 tsdown 改为打包 TSC 输出的 JS,使 TypeScript 转换行为来自同一个编译器。 +- vendor/ 中有两个逐包覆盖配置(属于我们的修改,与重新生成的 tsconfig 一样;记录在 vendor/README.md 中):schemastery(通过 `outExtensions` 输出双格式 `.mjs`/`.cjs`)、logger-console(两次单入口 pass,使共享基类内联到每个入口而非生成 hash 命名的 chunk,与上游发布形态一致)。 +- 删除 `scripts/build.ts`;`pnpm run build` = `tsc -b tsconfig.build.json && tsdown`。 + +## 曾考虑的替代方案 + +- **直接编写 esbuild 脚本**:最成熟的引擎,零包装层风险,但需要手动维护 tsdown workspace 模式自动提供的逐包规格表。 +- **pkgroll**:理念上最接近的直接替代品,但每周仅 78k 下载且基于 Rollup:维护前景严格弱于 tsdown。 +- **保留 dumble**:与上游完美对齐,但 bus factor 不可接受。 + +## 后果 + +运行时打包产物仍遵循 dumble 时代的公开入口形态(`lib/index.js`,加上包特有的变体如 `schemastery` 的 `lib/index.mjs`/`lib/index.cjs` 和 `logger-console` 的 `lib/browser.js`);声明文件现在按 [TSC 优先构建 RFC](2026-06-17-ts-build-config.md) 放在 `lib/types` 下。外部依赖仍来自各包的 dependencies/peerDependencies。我们放弃了 dumble 的 exports 字段推断能力:入口形态非默认的新包需要一个逐包的 `tsdown.config.ts`,而不能仅靠 package.json 字段。未来选项:如果 `tsc -b` 成为瓶颈,tsdown 也可以接管声明文件打包(isolatedDeclarations);那将是一个新的 RFC。 diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml new file mode 100644 index 0000000000..baaef0f47c --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-vendor-cordis-as-source.md: 39506300dec73d0c9eb1b7b2246caa23f1b10f7f +2026-06-11-vendor-cordis-as-source.zh.md: 1c942291481f50f602d6733e3c25a892885d47fe diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md index 2aa24907d5..39506300de 100644 --- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md @@ -1,5 +1,7 @@ # RFC: Vendor Cordis as source, not npm dependencies +English | [中文](2026-06-11-vendor-cordis-as-source.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md new file mode 100644 index 0000000000..1c94229148 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md @@ -0,0 +1,27 @@ +# RFC:以源码形式收录 Cordis,而非 npm 依赖 + +Status: implemented + +[English](2026-06-11-vendor-cordis-as-source.md) | 中文 + +## 问题 + +DeepSeek Harness SDK 基于 Cordis 框架构建。本仓库启动时,Cordis core 处于 4.0.0-rc.6(一个发布候选版本);harness 依赖框架内部实现(fiber 生命周期、effect dispose(资源释放)、waterfall(瀑布式事件)分发),这些行为的精确语义直接关系到 agent loop(智能体循环)的正确性保证。 + +## 决策 + +将所需的 Cordis 包(core、loader、include、group、timer、hmr、logger-console)及 cordiverse 基础库(cosmokit、schemastery)以源码形式扁平复制到 `vendor/`,保留其原始 npm 包名,使 workspace 解析透明。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍留在 npm。 + +`vendor/README.md` 是 manifest(元数据清单):记录每个包的上游仓库 + commit SHA,以及一份详尽的本地修改日志。pre-commit 守卫(`scripts/check-vendor-manifest.sh`)会拒绝未在同一次提交中更新 manifest 的 vendor 源码改动。 + +## 曾考虑的替代方案 + +- **依赖 npm 包**:否决。core 处于发布候选阶段,且 harness 依赖框架内部实现(fiber 生命周期、effect dispose、waterfall 分发),agent loop 的正确性保证取决于这些行为的精确语义;上游 RC 版本升级可能在没有本地修复路径的情况下破坏它们。 +- **传递性地收录所有依赖**:否决。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍留在 npm;只有内部实现对我们有影响的框架层才被纳入自有管理。 + +## 后果 + +- harness 完全拥有其框架层:可审计、可打补丁、版本锁定。上游 RC 无法破坏我们,框架 bug 可以在仓库内直接修复。 +- 上游同步是手动的(manifest 中记录了操作步骤)。修改日志使 diff 面始终可知。 +- vendor 包保留上游代码风格;lint 与严格性门禁将其排除(它们的 tsconfig 在本地放宽了我们较新的编译器 flag)。 +- 从第一天起就存在一个本地补丁:移除了 HMR 的 locale-YAML 导入(运行时 YAML 导入钩子未被收录)。 diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml new file mode 100644 index 0000000000..c2b598e0db --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-16-pnpm-over-yarn.md: 6e7a6e1f53056e36f54f44b87b305afa593da549 +2026-06-16-pnpm-over-yarn.zh.md: 809f10dbd63d347eccb4d00641a39da51788ee9f diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md index 7a574a3388..6e7a6e1f53 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -1,5 +1,7 @@ # RFC: pnpm as the package manager instead of Yarn 4 +English | [中文](2026-06-16-pnpm-over-yarn.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md new file mode 100644 index 0000000000..809f10dbd6 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -0,0 +1,43 @@ +# RFC:以 pnpm 替代 Yarn 4 作为包管理器 + +Status: implemented + +[English](2026-06-16-pnpm-over-yarn.md) | 中文 + +## 问题 + +本仓库最初使用 **Yarn 4** 搭配 `node-modules` linker 发布——这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时提供 Yarn 的 workspace 和 `yarn constraints`。它能用。但 Yarn 4 的 Plug'n'Play 血统使得 `node-modules` linker 成为非主流模式,而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent 构建、偶尔有人类贡献者阅读的仓库来说,「大多数工具和人所预期的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的答案。 + +切换成本目前处于最低点。本仓库尚无任何包发布(所有 package 均为 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到 (a) 解析并链接 `node_modules`,(b) 运行 workspace 脚本,(c) 强制执行 workspace 约束。唯一的 Yarn 专属资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](2026-06-11-tsdown-over-dumble.md)的逻辑一致:趁爆炸半径还小,把承重工具换成生态更健康的选项。 + +## 决策 + +采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定、经 Corepack 安装(与 Yarn 使用的机制相同): + +- **Workspace** 从 `package.json` 的 `workspaces` 数组加 `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml`(`vendor/*`、`packages/*`——相同的 glob;`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。 +- **严格符号链接 linker**(pnpm 默认)取代 Yarn 的 hoisted `node-modules` linker。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会让幽灵依赖(引用未声明的传递依赖)大声失败,这对一个以机械门禁为整体质量策略的仓库而言是一个*优点*(见[机械质量门禁](2026-06-11-quality-gates.md))。门禁套件——类型检查、lint、测试、构建、knip——是安全网,证明不存在此类幽灵引用。 +- **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非显式列入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild`、`lefthook`、`@google/genai`、`protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在将其扩展到安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。 +- **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`、使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,以 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:所有 package `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围匹配、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查 privacy。 +- 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词统一改为 `pnpm …` / `pnpm run …`。`yarn.lock` → `pnpm-lock.yaml`(lockfile v9)。`.gitignore` 将 `.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保留其上游的 `yarn` 示例不动。 + +## 曾考虑的替代方案 + +- **保留 Yarn 4**:零变动,但押注于使用者更少的 linker 模式和绑定单一包管理器的约束引擎。 +- **npm workspaces**:无处不在,但没有约束机制,monorepo 人体工学也更弱。 +- **pnpm 搭配 hoisted linker**:迁移更平滑,但放弃了幽灵依赖安全性——而这正是迁移的首要正确性理由。 + +## 后果 + +约束检查失去了 Yarn 的自动**修复**能力(`workspace.set()` 可以就地改写 manifest);tsx 脚本仅做检查,不通过时以非零退出码加消息退出。这是可接受的:CI 从未运行过 `--fix`,且需要手动改一行的情况很少。贡献者现在为 pnpm 而非 Yarn 运行 `corepack enable`;`pnpm exec lefthook install` 取代 `yarn lefthook install`(`postinstall` 钩子仍会运行 `lefthook install`)。 + +性能(迁移时在开发 NFS 文件系统上测量;单次运行样本,方差大——仅供方向性参考,非基准测试套件): + +| 场景 | Yarn 4 | pnpm 11 | +|---|---|---| +| Cold (empty cache/store, no `node_modules`) | ~14 s | ~16 s | +| Warm relink (cache/store warm, `node_modules` removed) | ~12–14 s | ~15–22 s | +| Frozen, `node_modules` present (no-op revalidate) | ~2–8 s | ~0.5–7 s | + +在快速本地磁盘上,pnpm 的内容寻址 store 通常在冷/热安装上胜出,尤其在多次 checkout 的**磁盘占用**上优势明显(一个全局 store 通过硬链接进入每个 `node_modules`,而 Yarn 每个 worktree 复制约 279 MB——部分开发者日常保持约 10 个或更多 worktree)。这一去重优势在上述迁移时数据中**未**体现,因为测试 store 和 `node_modules` 位于不同文件系统,硬链接失效;在单文件系统的开发机或 CI 缓存上该优势成立。诚实的总结:在我们的 NFS 开发文件系统上,安装速度在噪声范围内不分伯仲;迁移的理由是生态对齐、幽灵依赖安全性和跨 checkout 磁盘去重——而非原始安装时间的胜出。 + +所有质量门禁(约束、类型检查、lint、doc-sync、100% test:coverage、构建、knip、publint、echo-agent 演示冒烟测试)在 pnpm 上原样通过,这是 linker 切换未引入幽灵依赖破坏的正确性证明。 diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml new file mode 100644 index 0000000000..9858ce0f2e --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-17-ts-build-config.md: cf70014b5873f74da8476c21dd71feedb956f59f +2026-06-17-ts-build-config.zh.md: f70619de5a48c7040e816c54d21f81772a51a90f diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md index 0687df250c..cf70014b58 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md @@ -1,5 +1,7 @@ # RFC: TSC-first build and one tsconfig +English | [中文](2026-06-17-ts-build-config.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md new file mode 100644 index 0000000000..f70619de5a --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md @@ -0,0 +1,78 @@ +# RFC:以 TSC 为核心的构建与统一 tsconfig + +Status: implemented + +[English](2026-06-17-ts-build-config.md) | 中文 + +## 问题 + +当时的 TypeScript 构建与类型检查配置存在以下问题: + +- `build` 使用 `tsc` 将 `packages/<group>/<pkg>` 和 `vendor/*` 下的 `.ts` 转换为 `.d.ts`,再用 `tsdown` 将 `.ts` 转换为打包后的 `.js`。这导致两个工具各自做一次 TypeScript 转换。 +- `typecheck` 倾向于通过一个根级 typecheck 配置来校验 package、vendor 源码、示例、测试和脚本。 + +目标是让 build 和 typecheck 使用一致的 tsconfig 边界与 TypeScript 解析/转换行为。build 应通过同一个编译器和配置生成 `.js`、`.d.ts`、`.js.map` 和 `.d.ts.map`,使发布产物与类型校验保持一致。 + +验证过程中发现了若干具体技术问题和可能的路径: + +- `tsdown` 使用 `oxc` 做 TypeScript 转换,其行为与 `tsc` 不同。 + - `tsdown` 输出的打包 `.d.ts` 与 Cordis 内部的相对模块增强(module augmentation)结构冲突。 + - tsc 的输出受 `allowImportingTsExtensions` 影响,因此需要确保生成的 `.js` 不会 import `.ts` 文件,且生成的 `.d.ts` 保留 NodeNext/Node16 可接受的显式相对说明符。为此,包内相对导入在 TypeScript 源码中使用显式 `.ts` 说明符,由 `rewriteRelativeImportExtensions` 在输出的 JS 中将其改写为 `.js`。 + - `tsdown` 输出的打包 `.js` 与 `tsc -b` 逐文件输出的 `.js` 行为不同,例如 decorator 转换行为。 +- `vendor/*/src`、示例、测试和脚本无法全部以 plain-include 方式放入一个根级严格程序。 + - 在根级严格配置下直接对 `vendor/*/src` 做类型检查,会触发大量不属于本项目的类型错误。 + - `packages/*/*` 对 `vendor` 的依赖解析到 `vendor/*/lib`,以适应不同的 tsconfig 严格度。 + + +## 决策 + +包内相对导入使用显式 `.ts` 说明符。 + +`pnpm run build` 分两阶段: + +- 阶段 1:`tsc -b tsconfig.build.json` 将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts` / `.d.ts.map`,忽略 `.js` / `.js.map`。 + - 构建项目使用 `tsc -b` 编译的 project-reference 图。例如,根 `tsconfig.build.json` 引用包和 vendor 的 tsconfig,校验并输出包/vendor 的构建结果。 +- 阶段 2:bundler 读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,不得读取 TypeScript 源码,也不得输出声明文件。 + +`tsdown` 不再负责 TypeScript 编译或声明文件输出。 + +`pnpm run typecheck` 以 build 模式运行根 `tsconfig.json`。 +- 根 `tsconfig.json` 是唯一的开发/类型检查项目。它以 `noEmit` 检查示例、测试和脚本,并通过 references 校验包/vendor 源码。 +- 被引用的包/vendor 项目保持与 build 相同的输出行为,因此 typecheck 可以刷新它们的 `lib/types` 产物,而无需使用单独的 no-emit 图。项目特有的严格度设置放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 +- 根 no-emit 项目禁用 `rewriteRelativeImportExtensions`;它不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的输出项目保持该改写启用。 + +命令编排如下: + +```sh +pnpm run build: +tsc -b tsconfig.build.json +tsdown + +pnpm run verify-node-next-types: +tsx scripts/verify-node-next-types.ts + +pnpm run typecheck: +tsc -b tsconfig.json +``` + +`pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。 + +## 曾考虑的替代方案 + +- **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(decorator 转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。 +- **一个根级严格程序覆盖包、vendor、示例、测试和脚本**:vendor 源码在根级严格 flag 下会触发不属于本项目的类型错误;带有各项目独立严格度的 project references 才是可行的边界。 + +## 后果 + +构建职责更加清晰: + +- `packages/<group>/<pkg>` 和 `vendor/*` 下的每个模块都有一个本地 tsconfig,同时服务于 build、typecheck 以及直接运行源码的工具(如 `tsx` 和 `vitest`)。 +- `build` 命令使用 `tsconfig.build.json`。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,bundler 只负责 `lib/index.*`。 + - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明文件产物。 + - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 + - `lib/types/*.js` 仅作为 bundler 输入,不得用作运行时入口或公开导入目标。 + - `lib/index.*` 是发布用的运行时产物,由 bundler(当前为 `tsdown`)生成。 +- `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 表面进行临时外部 ESM 消费方的类型检查,使声明说明符的回归在发布前即被捕获。 +- `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 + +Cordis vendor 副本现在与上游多了一处类型结构差异。上游同步时,必须重新应用该差异或明确将其退役。 diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml new file mode 100644 index 0000000000..3773aca2f5 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-18-markdown-cross-link-lint.md: c802b4071abf652824647e4417cde3f518776353 +2026-06-18-markdown-cross-link-lint.zh.md: abbb5930acb18287effc7c47009b9e3e910f6c89 diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md index db98c2fb7d..c802b4071a 100644 --- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md @@ -1,5 +1,7 @@ # RFC: Markdown cross-link validity linting +English | [中文](2026-06-18-markdown-cross-link-lint.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md new file mode 100644 index 0000000000..abbb5930ac --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md @@ -0,0 +1,33 @@ +# RFC:Markdown 交叉链接有效性 lint + +Status: implemented + +[English](2026-06-18-markdown-cross-link-lint.md) | 中文 + +## 问题 + +本仓库的文档通过相对路径互相链接:`[topic](../implemented/2026-…-….md)`、`[the cookbook](adding-a-tool.md)`、`[architecture.md](../../architecture.md)`。此前没有任何机制验证这些目标是否存在。一次重命名或移动会悄无声息地打断所有入站链接,直到读者点击时才会发现。[Doc-sync 强制](2026-06-11-doc-sync-enforcement.md)已经将两类文档漂移机械化了(不可编译的代码块、陈旧的事件分类体系表),[verify-md-wrap](2026-06-11-doc-sync-enforcement.md) 处理了第三类(硬换行的行文段落),但死链接是第四类同样可机械检查的问题,此前仍靠肉眼验证。 + +直接触发本门禁的案例是引入它的那次 RFC 目录重组:将 `docs/adr/` + `docs/rfc/` 统一为一个 `docs/rfc/`,下设 `proposed/`、`implemented/`、`rejected/` 子目录,手动改写了约四十条文档间链接。任何一条路径的手误都会让断链随代码一起合入,而没有任何东西能拦住它。 + +## 决策 + +新增第四道 `doc-sync` 门禁 `verify-md-links`(`scripts/verify-md-links.ts`),风格与 `verify-md-wrap` 一致(tsx ESM、基于 AST、只验证不生成): + +- 用 `mdast-util-from-markdown` + GFM 解析范围内的每个 Markdown 文件,遍历所有 `link`、`image` 和 `definition` 节点。 +- 仅当目标是**相对路径**时才检查。跳过带协议的 URL(`https:`、`mailto:` 等)、协议相对路径(`//host`)、根绝对路径(`/path`——在 checkout 中没有稳定基准)以及纯页内锚点(`#section`)。去除 `#fragment`/`?query`,相对于链接所在文件的目录解析路径,并断言该路径在磁盘上存在。 +- 只报告,不改写;发现第一条断链即以非零状态退出。 + +范围与其他门禁一致,另加 AGENTS.md 对和 `.agents/skills/` 下仓库自有的 agent skill Markdown(这些 skill 文件交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`,按真实路径去重(`CLAUDE.md` 符号链接解析到 AGENTS.md 文件)。该门禁接入 lefthook pre-push 钩子和 CI 都会运行的 `doc-sync` 脚本,因此断链在推送前就会在本地失败——与[机械质量门禁](2026-06-11-quality-gates.md)保持一致。 + +本门禁检查的是**文件存在性**,而非锚点有效性:链接到一个真实文件但带有 `#wrong-heading` 片段的仍然通过(文件可解析;片段被剥离)。 + +## 曾考虑的替代方案 + +**锚点级有效性检查**:更重且价值更低;实际造成问题的是文件级死链接。这一范围裁剪是有意为之:作者在链接到某个锚点时自行验证 `#fragment`。 + +## 后果 + +- 重命名或移动导致交叉链接悬空时,pre-push 钩子和 CI 会立即失败,而不是等读者点击死链接才发现。这使得引入本门禁的 RFC 重组具有自验证性:同一个 PR 既改写了四十条链接,也加入了证明无一悬空的检查。 +- `doc-sync` 链中多了一个快速 tsx 脚本;无新增依赖(mdast/GFM 技术栈已在 devDependencies 中供 `verify-md-wrap` 使用)。 +- 本门禁强制的约定——通过可机械检查的相对链接交叉引用文档,而非裸文字或编号——记录在 [docs/AGENTS.md](../../../AGENTS.md) 中,让作者知道这道门禁的存在及其原因。 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml new file mode 100644 index 0000000000..2421ac85fc --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-core-data-structures-catalog.md: 5f1232f2f0d0644d4043af217a7177451155030b +2026-06-20-core-data-structures-catalog.zh.md: d35a4d9d32eb59971bbf8b105d01dd38c0811fb0 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index aaac6fbd3c..5f1232f2f0 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -1,5 +1,7 @@ # RFC: Core-data-structures catalog and the `ts type-equiv` drift gate +English | [中文](2026-06-20-core-data-structures-catalog.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md new file mode 100644 index 0000000000..d35a4d9d32 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -0,0 +1,60 @@ +# RFC:核心数据结构目录与 `ts type-equiv` 漂移门禁 + +Status: implemented + +[English](2026-06-20-core-data-structures-catalog.md) | 中文 + +## 问题 + +想要理解 harness 的读者可以在 [architecture.md](../../../architecture.md) 中找到它的*行为*(服务映射、会话/轮次/步骤生命周期、事件分类体系),但没有一个集中的地方描述它的*词汇*——那些行为所操作的数据结构。类型定义只存在于源码中,散落在各个 `packages/*/src/types.ts` 里,因此要理解「什么是 `Message`、`SessionEvent`、`StreamChunk`」就意味着直接阅读声明。一份行文目录会有帮助,但如果目录是对类型定义的转述或粘贴复制,那么字段一改它就会腐烂——而一份失去同步的类型文档比没有更糟,因为读者会信任它。 + +因此这项工作包含两个交织的问题:**这样一份目录应当收录什么**(范围界定问题:一个 harness 有数十个跨包(package)的类型,全部堆上去对谁都没帮助),以及**如何防止粘贴的类型定义漂移**(持久性问题)。本 RFC 记录这两项决策。它的姊妹篇 [生成式 Cordis 事件 + 服务目录](2026-06-20-generated-cordis-catalog.md) 是*接线*轴向的补充:本篇编目数据结构,那篇编目移动它们的事件与服务。 + +## 决策 + +新建 `docs/core-data-structures/` 目录编目词汇,并新增 `verify-type-equiv` doc-sync(文档同步门禁)门禁,确保每一处粘贴的类型定义与源码逐字节一致。 + +### 什么算「核心」——主干与 seam 的分界线 + +范围界定不是自上而下拍板的,而是将候选定义逐一对照具体的边界类型反复测试,直到一条规则在所有案例中都成立。决定性的测试是 `BashExecRequest`/`BashExecSpec`/`BashRunResult`:bash 是一个能力 *seam*,不属于 agent loop 主干;如果这些算「核心」,那「核心」就等于*所有跨包词汇*,目录就是一份平铺的全量转储;如果它们不算,「核心」就意味着*中央主干*,bash 词汇属于子页面。后者胜出,由此确定了整体结构:一个**分层目录**,而非一份平铺文档。 + +解决剩余案例的规则:***你编写、持有或接收的类型是核心;为其提供类型推导、渲染或持久化的机制是子页面细节。*** 逐一验证如下: + +- 一个数据结构是**核心**的,如果它流经 agent loop 主干——无论加载了哪些插件,循环在每个轮次都持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄)——**或者**它是插件作者面向某条流水线编写的唯一标题类型(`ToolDefinition`)。 +- `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标题类型,撰写重要性覆盖了严格的「流经主干」规则。但它的类型推导机制——`SchemaSpec`/`InferArgs` DSL——是子页面细节(你编写的是 `ToolDefinition`;为其提供类型推导的机制你不直接接触)。这就是主干与 seam 分界线的精确表述。 +- `ToolSchema` 是核心(它是 `GenerateOptions` 的字段,而 `GenerateOptions` 是流经每个步骤的模型请求),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 +- 工具展示词汇(`ToolCallView`/`ToolResultView` 等)、`SessionPersistence` 持久性 seam 以及 bash 词汇是子页面。 + +`core.md` 是一份**自包含的主干文档**:它给出每个主干结构的确切类型定义,配以最少的行文,并链接到各 seam 细节的子页面。子页面包括 `llm-streaming.md`、`session.md`、`persistence.md`(沿内存模型与持久性 seam 的分界从 session 中拆出)、`tools.md` 和 `bash.md`。 + +### `ts type-equiv` 机制——逐字且防漂移 + +持久性要求很具体:文档应展示**逐字**的当前类型定义(让读者看到真实形状,而非转述),**并且**机械地保证与源码一致。仓库已经能编译围栏 ` ```ts ` 块(`doc-typecheck`),但一个真正通过类型检查的块需要 import 噪音,且只能证明*可赋值性*而非*逐字节相等*——一个改了名但类型相同的字段仍能通过。因此: + +- 类型定义逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。`doc-typecheck` 识别该围栏并跳过它(裸定义不能独立编译),并**将其排除在 opt-out 比例之外**——它是一个独立检查的类别,而非未检查的草稿。 +- 新增 `scripts/verify-type-equiv.ts`,通过 TypeScript 解析器提取每个块,并对声明的符号断言**逐字源码匹配**——之所以选择这种方式而非编译式 `_Check` 可赋值性断言,正是因为逐字节相等而非可赋值性才是我们需要的属性。 +- 来源信息保存在中央 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有条目腐烂。 +- 接入 `doc-sync`,因此与其他文档门禁在同一个 lefthook pre-push 和 CI 路径中运行。 + +### 维护是作者的职责,门禁作为兜底 + +`verify-type-equiv` 能捕获已记录类型的*粘贴漂移*,但无法告诉你一个全新的核心类型没有被记录。因此 AGENTS.md 和 `dsh-code-review` skill 已更新,要求在变更添加或重塑已记录类型时同步更新目录——门禁处理漂移,人处理新增表面。 + +## 曾考虑的替代方案 + +- **平铺转储所有跨包词汇**:`BashExecRequest` 测试案例否决了它。如果 seam 词汇算「核心」,目录对谁都没帮助;分层的主干与 seam 结构胜出。 +- **编译式 `_Check` 可赋值性断言**替代逐字源码匹配:否决,因为逐字节相等而非可赋值性才是我们需要的属性——一个改了名但类型相同的字段能通过可赋值性检查。 +- **来源信息作为行文中的指令注释**:否决,改用中央 manifest;其强制的 1:1 对应确保不会有块被静默漏检,也不会有条目腐烂。 + +## 验证教训 + +主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及 session/persistence 拆分的逐一测试。 + +`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是 manifest 中列出的文档。否则未登记的 `type-equiv` 块会逃脱所声称的一对一检查。因此门禁将此类块报告为遗留块。本 RFC 将这条快速失败的扫描规则与主干/seam 分界和逐字匹配决策一并记录;生成式 Cordis 目录在[其 RFC](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 + +## 后果 + +- 词汇现在有了一个**不会静默漂移**的唯一归属地:源码中的字段重命名会在 pre-push 钩子和 CI 中使 `verify-type-equiv` 失败,直到粘贴内容被刷新。 +- 主干与 seam 分界线是一个可复用的范围界定工具,而非一次性决策:同一条「你编写/持有/接收的东西是核心;为其提供类型推导/渲染/持久化的机制是细节」规则,后来也被用于界定事件/服务目录的 harness 层与继承层分层。 +- `ts type-equiv` 围栏是继 ` ```ts `(编译)和 ` ```ts ignore-check `(草稿)之后的第三种文档块类别。后续又新增了第四种 ` ```ts cordis-catalog `(生成签名),复用了相同的跳过并排除处理。 +- 添加或重塑核心类型现在附带一项文档义务,作者必须履行(门禁无法检测缺失的*新*类型),由 `dsh-code-review` 检查清单兜底。 diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml new file mode 100644 index 0000000000..ee4e1d24fc --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-generated-cordis-catalog.md: 6b451e31965f8f00210aa927ed236fed28699351 +2026-06-20-generated-cordis-catalog.zh.md: 9c7150ff5f12d4d9e4a13158acdf8f52e84fd95a diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index eafce4feae..6b451e3196 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -1,5 +1,7 @@ # RFC: Generated cordis events + services catalog +English | [中文](2026-06-20-generated-cordis-catalog.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md new file mode 100644 index 0000000000..9c7150ff5f --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md @@ -0,0 +1,41 @@ +# RFC:生成式 Cordis 事件 + 服务目录 + +Status: implemented + +[English](2026-06-20-generated-cordis-catalog.md) | 中文 + +## 问题 + +插件作者需要两个参考面,而此前没有任何单一文档能提供:他们可以监听的每一个 Cordis **事件**(含精确签名与分发模式),以及他们可以调用的每一个 `ctx.<key>` **服务**(含精确接口)。相关信息已经存在,但散落各处:`docs/architecture.md` 中一张手工维护的事件分类*表格*(名称 + 行文描述的 Mode/Purpose,由 `verify-event-taxonomy` 做名称集合校验)、一张服务映射表(8 行角色描述),以及 `interface Events` / `interface Context` 声明本身。分类表还有一个盲区:它无法捕获全新的*未记录*事件——名称集合校验器只检查两侧已有的名称。 + +这是[核心数据结构目录](../../../core-data-structures/core.md)([对应 RFC](2026-06-20-core-data-structures-catalog.md))在连线轴上的互补件:那份目录记录 agent loop 流转的*数据结构*(经校验的手工粘贴);本目录记录流转它们的*事件与服务*。 + +## 决策 + +从源码生成目录,而非手工维护表格再校验子集。 + +`scripts/gen-cordis-catalog.ts` 使用 TypeScript 编译器 API,从声明和源码 JSDoc 分别输出事件参考与服务参考。事件包含分发模式;服务包含公开签名。确定性的 `--write` 与 `--check` 模式使两个页面成为生成产物,新鲜度由 doc-sync 强制。 + +纯生成在这里是正确的,因为代码库足够规范,AST 即全部真相:每个事件/服务名称都是字符串字面量,能往返映射到一个静态声明——没有动态命名的事件,也没有仅运行时存在的服务。因此生成的文档不可能出错,并且从结构上消除了未记录事件的缺口(生成器枚举源码,而非检查手写子集)。 + +具体选择: + +- **`@mode` 标签,交叉校验。** 每个 harness 事件的 JSDoc 携带显式的 `@mode emit|waterfall|parallel|serial` 标签;缺少标签时生成器直接报错。当签名形状具有结论性时——尾部参数为 `next: () => …` 在结构上即为 waterfall——生成器断言标签与之一致,矛盾时直接报错。emit/parallel/serial 的区分在结构上不可见(`session/flush` 返回 `Promise<void> | void` 且无 `next`,有序的 `agent/pre-step` 检查点亦然),因此信任标签。撰写规则见 [AGENTS.md](../../../../AGENTS.md)。 +- **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/HMR/timer)是插件同样可见的固定 vendor 源;它以精简形式渲染(名称 + 一行说明 + 源码指针),数据来自生成器中的一张手工策展表,而**不是**遍历 vendor AST——cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 表面仅在有意的 vendor 同步时才变化。 +- **交叉链接到数据结构目录。** 签名中出现的类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition` 等)链接到记录该类型的核心数据结构页面。映射是生成器中一个小型手工策展的 const,而**不是** `type-equiv.manifest.json`——后者记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 +- **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,`doc-typecheck` 识别并跳过它(裸签名片段不能独立编译),不计入 opt-out 比例——与 `type-equiv` 块的处理方式相同。 + +本决策**取代** [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)中的事件分类部分:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射角色表作为策展行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 与 verify-type-equiv 不受影响。 + +## 曾考虑的替代方案 + +- **校验而非生成(退役的分类检查所做的事)**:*仅对此表面*反转了方向。这里的数据可以机械地完整获取,因此生成严格强于名称集合校验(完整签名、不会漂移、能捕获未记录事件)。 +- **遍历 vendor AST 以获取继承层**:否决,改用策展表。cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段,且固定的 vendor 表面仅在有意同步时才变化。 +- **复用 `type-equiv.manifest.json` 作为签名交叉链接映射**:否决,改用小型手工策展 const。manifest 记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 + +## 后果 + +- 目录不会漂移:源码变化而已提交文件未反映时,`verify-cordis-catalog` 在 pre-push 钩子和 CI 中失败。新事件缺少 `@mode` 标签、或标签与签名矛盾时,生成器直接报错。 +- 事件的行文描述现在只有一个归属地——声明处的 JSDoc。JSDoc 写得薄,目录条目就薄,这迫使作者在源头做好文档(生成器是 AGENTS.md「每个导出都有语义 JSDoc」规则的强制函数)。 +- 继承层是手工摘要的,因此 vendor 同步若增加或重命名了 cordis-core 事件或 `ctx` 成员,需要同步编辑 `gen-cordis-catalog.ts` 中的策展表。这是不遍历固定 vendor 源的有意代价;变化很少,且在生成器中有明确标注。 +- `verify-event-taxonomy.ts` 被删除,`docs/architecture.md` 的事件表格消失;之前链接到特定表格行的人现在会落到生成目录上。 diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml new file mode 100644 index 0000000000..97b96e7315 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-rfc-classification.md: 201852a209be7f40b05de45d148a36b9185767a3 +2026-06-20-rfc-classification.zh.md: 38eb920ac216e69087f0c84c95cdd9effca7b9b3 diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md index 2162687225..201852a209 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md @@ -1,5 +1,7 @@ # RFC: Classify RFCs by kind via path-encoded subdirectories +English | [中文](2026-06-20-rfc-classification.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md new file mode 100644 index 0000000000..38eb920ac2 --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md @@ -0,0 +1,48 @@ +# RFC:通过路径编码的子目录对 RFC 进行分类 + +Status: implemented + +[English](2026-06-20-rfc-classification.md) | 中文 + +## 问题 + +`docs/rfc/` 此前仅按**生命周期**分组:`proposed/`/`implemented/`/`rejected/`。没有任何机制记录每篇 RFC 属于哪一*类*决策。索引是每个生命周期下的一个扁平列表,无法按需筛选「所有精简类」或「所有测试策略类」决策。同一天落地的一批精简类 RFC 让这个缺口变得具体:浏览 `proposed/` 的读者无法在不逐一打开文件的情况下区分新能力、移除和工具策略变更。 + +本仓库的一贯倾向是[机械质量门禁优先于行文指南](2026-06-11-quality-gates.md):不被机器检查的约定终将腐烂。因此这里的分类体系必须可强制执行,而非靠自觉的文件头。 + +## 决策 + +增加第二个维度——RFC 的**类别**——并将其编码在路径中:`{lifecycle}/{class}/yyyy-mm-dd-topic.md`。文件夹*就是*标签。文件的位置声明其类别,封闭集合是「这些文件夹且仅限这些」,而既有的 [verify-md-links](2026-06-18-markdown-cross-link-lint.md) 门禁已经保护了移动文件所需的路径重写。 + +### 六个类别的封闭集合 + +| 类别 | 覆盖范围 | +|---|---| +| `feature` | 面向用户或模型的新能力。 | +| `bug-fix` | 修正缺陷或弥补事后复盘暴露的缺口。 | +| `simplification` | 移除代码、行为或接口面,不增加新能力。 | +| `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇是什么。 | +| `process` | 围绕代码的工具、策略或工作流,不涉及运行时行为。 | +| `testing` | 测试基础设施与策略。 | + +`architecture` 与 `process` 的分界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。本 RFC 本身是一项 `process` 决策——它改变的是仓库的组织方式和门禁,而非 harness 在运行时的行为——因此它位于 `implemented/process/` 下。 + +### 两道门禁 + +两者都是 `doc-sync` 的成员,风格与 `verify-md-wrap` 一致(tsx ESM,只校验不生成,首个违规即以非零退出码退出): + +- **`scripts/verify-rfc-classification.ts`**:封闭集合与索引新鲜度(freshness)。它断言每个生命周期文件夹下的文件都位于规范集合中的某个类别文件夹内(直接放在生命周期根目录的 `.md`,或未知的类别文件夹,都会失败),并断言生成的 [INDEX.md](../../INDEX.md) 与从目录树重新渲染的结果逐字节一致(见[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md))。规范类别集合以 `const` 形式定义在 `scripts/rfc-index.ts` 中——这是与生成器共享的机器真源——[README](../../README.md) 以行文形式记录它;类别*描述*保持手写,索引则是生成的。 +- **`scripts/verify-doc-refs.ts`**:源码注释中的文档引用。RFC 路径不仅在 Markdown 中被引用,也出现在 TypeScript 文档注释中(根相对路径,如 `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`)。`verify-md-links` 从未扫描过这些引用,因此重组可能会悄悄使它们变成悬空引用。此门禁扫描 `packages/**` 和 `examples/**` 下仓库自有的 `.ts` 文件(排除构建产物 `lib/` 和 `vendor/`),查找 `docs/….md` 形式的 token,将每个根相对路径解析并断言其存在。它要求 `.md` 扩展名,因此无扩展名的行文引用(`docs/postmortem/0001`、`docs/architecture.md § Extending The Harness`)不受影响。 + +## 曾考虑的替代方案 + +- **在每个文件中加一行 `Classification:` 行文**(紧挨 `Status:`),由门禁解析。可行,但它把路径已经能承载的事实重复到了文件内,而且这一行可能与所在文件夹不一致。路径编码让标签与其存储合二为一——没有需要保持同步的东西。 +- **设立 `refactor` 类别。**它与 `simplification` 几乎完全重叠;唯一有人试图用来区分的标准是「可观测行为是否改变?」,而 `simplification` 已经编码了这一点(它不改变)。一个类别,不要两个。 +- **从文件系统自动生成索引。**此处最初否决,以保持索引手写;后来被[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md)取代——当堆叠的提案波使手写表格成为仓库中冲突最频繁的文档区域后,列表改为完全生成的 [INDEX.md](../../INDEX.md),而 README 行文保持人工策展。 + +## 后果 + +- 每篇 RFC 现在都位于一个类别文件夹下,索引在每个生命周期内按类别分组。读者扫一个标题就能看到所有精简类或所有测试类决策。 +- `doc-sync` 链中多了两个快速 tsx 脚本;无新增依赖(mdast/GFM 栈已因 `verify-md-wrap`/`verify-md-links` 而存在)。 +- 新增类别是一个刻意的动作:修改 `scripts/rfc-index.ts` 中的 `const` 以及 [Classification 章节](../../README.md#classification),而不是仅仅 `mkdir` 一个文件夹。门禁会拒绝未知文件夹,因此临时类别无法悄悄混入。 +- 源码注释中的文档引用现在也受门禁保护:一个被移动或重命名的文档如果被 `.ts` 注释引用,pre-push 钩子就会失败,从而封堵了 `verify-md-links` 在结构上无法看到的一类漂移。 diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml new file mode 100644 index 0000000000..852f69db42 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-02-tool-schema-catalog.md: 9a99fafd36b3546be4f51a7cd9e9a47fdaaf4c2d +2026-07-02-tool-schema-catalog.zh.md: 373c681fa5870696645b138f12cad7f296434184 diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 7d79f73583..9a99fafd36 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -1,5 +1,7 @@ # RFC: Generated tool-schema catalog (boot-and-harvest) +English | [中文](2026-07-02-tool-schema-catalog.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md new file mode 100644 index 0000000000..373c681fa5 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -0,0 +1,55 @@ +# RFC:生成式工具 schema 目录(启动并采集) + +Status: implemented + +[English](2026-07-02-tool-schema-catalog.md) | 中文 + +## 问题 + +仓库此前没有一份统一的参考,列出实际暴露给模型的工具名称、描述与 JSON Schema。源码声明分散各处且在运行时组合,而既有的 Cordis 目录和数据结构目录覆盖的是接线与词汇,而非工具本身。 + +## 决策 + +通过**启动每个工具插件并读取其注册的 schema** 来生成目录,而非解析源码。`scripts/gen-tool-catalog.ts` 将每个已发布的工具包(package)挂载到一个全新的 Cordis `Context` 上(带 `SystemPrompt` + `ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`(即发送给模型的 `ToolSchema[]`),dispose 上下文,然后为每个包渲染一个 `## <package>` 小节,每个工具对应一个 ` ```json ` 的 `parameters` 块。它沿用 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形态:默认 `--write` 重新生成,`--check` 在已提交副本陈旧时失败,输出是确定性的(按 manifest 排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)运行在 doc-sync 内部,因此新鲜度门禁与其他文档门禁一样在 lefthook pre-push 和 CI 路径中触发。 + +### 为什么启动而非解析(核心论点) + +Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是字符串字面量,能往返映射到静态声明——AST 就是全部事实。**工具 schema 不是静态可知的**,因此同样的技术会产出一份说谎的文档: + +- `tool-todo` 写了 `enum: [...STATUSES]`——对一个运行时 `const` 的展开。AST 看到的是展开表达式,而非 `["pending","in_progress","completed"]`。 +- 每段 description 都由字符串**拼接**构建(`'…' + '…'`)。AST 看到的是拼接节点,而非模型实际读到的最终文本。 +- `tool-subagent` 的工具名是 `config.toolName ?? 'subagent'`——加载时选定,不是字面量。 +- MCP 插件可以通过 `ctx.tools.register()` 直接注册**原始 JSON Schema**,完全不经过 `defineTool`,因此结构化枚举 `defineTool(` 调用点会漏计。 + +唯一忠实的真源是插件加载后注册表实际持有的 schema。启动即是[测试策略](../../../testing.md)中「验证世界,而非自我报告」这一原则在文档生成器上的应用:读取已发布的产物,而非对它的重新推导。 + +### 恢复「不会静默遗漏」 + +启动有一项 AST 遍历不具备的代价:没有源码声明集合可供枚举,因此新增的工具包可能被遗忘。一道**完整性守卫**恢复了这一保证——`assertManifestComplete` 对 `packages/` 下所有 `tool-*` 包做 glob,若有任何一个不在生成器的启动 manifest 中则硬报错。新增工具包会导致生成器失败,进而导致 doc-sync 失败,直到该包被注册。这与 Cordis 生成器通过枚举源码免费获得的结构性保证相同,只是为启动式生成器重新实现了一遍。 + +### 手工维护的启动 manifest 是不可约减的策略 + +文件系统负责发现工具包清单,完整性守卫负责拒绝遗漏。`TOOL_PACKAGES` 仍然为每个包持有一份显式的启动配方,因为所需的 seam 实现和配置是**策略**,不是能从目录布局或注入名称安全推断的事实。 + +### 范围 + +`packages/*/tool-*` 下已发布的产品级工具包,各以默认配置启动:`dsh-tool-bash`(`bash`、`bash_output`、`bash_kill`)、`dsh-tool-todo`(`todo_write`)、`dsh-tool-subagent`(`subagent`)。`examples/` 下的演示工具(`echo`)被排除,与 Cordis 目录的 packages-only 范围一致——演示工具不属于读者所要查阅的产品接口。 + +目录的单位是包,而非每个已配置的工具实例。每个包以默认配置启动一次;加载时的别名(如 `subagent_fork`)会注明,但不枚举每种部署排列。部署清单是一个独立的、无界的接口。 + +### 使用普通 `json` 围栏 + +schema 块使用 ` ```json `,而非自定义的 `ts` 系围栏。`doc-typecheck` 只提取 `ts*` 围栏,因此 JSON 块对它不可见——无需 `BlockKind` 接线(不同于 Cordis 目录的 `ts cordis-catalog` 围栏,后者必须加入白名单以避免裸签名片段被编译)。 + +## 曾考虑的替代方案 + +- **纯 TypeScript AST 遍历,如 Cordis 目录**:工具 schema 不是静态可知的(见上文核心论点):运行时展开、字符串拼接、配置选定的名称,以及原始 `ctx.tools.register()` 注册,都会让 AST 推导出的文档说谎。 +- **从各包的 inject 推断启动配方**:[发现包清单提案](../../proposed/process/2026-06-20-discover-package-inventory.md)所警告的「过于聪明」的路径;配方保持手写策略,清单由文件系统发现并受完整性守卫保护。 +- **为 schema 块使用自定义 `ts` 系围栏**:不必要。普通 ` ```json ` 围栏对 `doc-typecheck` 不可见,无需 `BlockKind` 白名单。 + +## 后果 + +- 目录不会漂移:工具 schema 变更而已提交文件未反映时,`verify-tool-catalog` 在 pre-push 钩子和 CI 中失败。新增 `tool-*` 包未加入 manifest 时,完整性守卫直接报错。 +- 工具描述文本只有一个归属地——源码中 `defineTool` 的 `description`——生成的条目质量完全取决于它,与 Cordis 目录对事件 JSDoc 施加的推动力相同。 +- 生成器导入并执行工作区包(这是仓库中第一个这样做的脚本;其他脚本只读取文本)。它通过根 `tsconfig` 的 `paths` 映射在 `tsx` 下运行,走的是演示和测试所用的同一条未构建源码路径,因此不需要构建步骤。 +- 未来某个工具背后新增能力 seam 时,意味着 manifest 中新增一条配方条目(需要挂载哪些 seam)。这是上文明确指出的手写代价;仅在新增工具包时才需变更。 diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml new file mode 100644 index 0000000000..7b522b590b --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-03-documentation-graph-atlas.md: d10b57e5114684ab0a2caed66fa84b84959bdf12 +2026-07-03-documentation-graph-atlas.zh.md: 8473bc27994bb33eac61659196acc53b69a16449 diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md index a17973ea2e..d10b57e511 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -1,5 +1,7 @@ # RFC: Documentation graph index for maintainers and SDK users +English | [中文](2026-07-03-documentation-graph-atlas.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md new file mode 100644 index 0000000000..8473bc2799 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -0,0 +1,69 @@ +# RFC:面向维护者和 SDK 用户的文档关系图索引 + +Status: implemented + +[English](2026-07-03-documentation-graph-atlas.md) | 中文 + +## 问题 + +仓库此前已有若干高可信度的文档面,各自覆盖不同维度:[module-graph.md](../../../module-graph.md) 由 package 的 `peerDependencies` 生成;生成的 [Cordis 事件目录](../../../cordis-catalog/events.md)和[服务目录](../../../cordis-catalog/services.md)由 Cordis 的 `Events` 与 `Context` 声明生成;[tool-catalog.md](../../../tool-catalog.md) 通过启动已发布的工具插件生成;[core-data-structures/](../../../core-data-structures/core.md) 使用 `ts type-equiv` 块保持粘贴的类型定义与源码同步。 + +这些参考资料是准确的,但它们大多是目录。维护者仍需自行综合关系:哪些 package 构成一条能力 seam、哪个应用捆绑了具体的主干、哪个事件是持久的而哪个是实时的、钩子或策略插件可以在哪里拦截工作、以及哪个面向模型的工具依赖哪个服务。SDK 用户从另一个角度面临同样的问题:「我想要某种行为,该安装或加载哪个 package?该扩展哪个事件/服务/工具?」 + +钩子子系统使事件的生产者/消费者拓扑与拦截点变得更加重要;文件系统 seam 使能力 seam、策略否决、工具呈现和 SDK 组装路径变得更加重要。如果关系图仅限于一个小的 bash/todo/subagent 表面,它们会立刻陈旧。 + +## 决策 + +新增生成的关系图文档,索引位于 [docs/graph-atlas.md](../../../graph-atlas.md),由专门的生成器产出,并由 `pnpm run verify-doc-graphs` 及既有的目录新鲜度检查(作为 `doc-sync` 的一环)进行验证。 + +该索引是既有目录之上的关系层。它不替代精确的参考资料,而是链接到它们并解释各部分如何组合在一起。 + +### 维护模式 + +每个关系图页面声明一种维护模式: + +- **生成(Generated)**:所有节点和边均从源码发现;如果已提交的产物陈旧,`--check` 失败。 +- **混合生成(Hybrid generated)**:源码发现清单,一份小型 manifest 对不可约的策略进行分类,完整性守卫在发现的条目未被分类时失败。 +- **人工维护(Curated)**:图表解释设计意图、时序或归属;它由生成器输出以保证关系图文档作为一个可重新生成的整体,但内容是有意撰写的。 + +### 首批交付的索引 + +首批索引链接十个关系面。package 拓扑与工具-package 能力映射位于既有的生成目录中(这些目录已拥有相应事实);其余的专项图表由 `scripts/gen-doc-graphs.ts` 生成。 + +| 关系图 | 维护模式 | 真源 | +|---|---|---| +| [模块依赖图](../../../module-graph.md) | 生成 | `packages/*/*/package.json` 的 peer dependencies 加 package 分组路径 | +| [工具 schema 目录与 package 映射](../../../tool-catalog.md) | 生成 | 启动采集的工具 schema 加工具-package 的服务/副作用元数据 | +| [能力 seam 与核心服务](../../../capability-seams.md) | 混合生成 | Cordis 服务声明加 `gen-doc-graphs.ts` 中的角色 manifest | +| [echo-agent 应用组合](../../../../examples/echo-agent/composition.md) | 混合生成 | `examples/echo-agent/cordis.yml` 插件列表加人工维护的应用/bundle 展开 | +| [coding-agent 应用组合](../../../../examples/coding-agent/composition.md) | 混合生成 | `examples/coding-agent/cordis.yml` 插件列表加人工维护的应用/bundle 展开 | +| [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | 混合生成 | `examples/acp-agent/cordis.yml` 插件列表加人工维护的应用/bundle 展开 | +| [事件生产者/消费者矩阵](../../../event-producer-consumer.md) | 混合生成 | Cordis 事件声明、AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 调用点,以及显式的动态分发覆盖 | +| [agent 轮次与步骤生命周期](../../../agent-lifecycle.md) | 人工维护 | architecture.md 的循环生命周期、Cordis 目录链接与会话事件语义 | +| [工具执行流水线](../../../tool-execution-pipeline.md) | 人工维护 | 工具流水线语义与 `tools/execute` waterfall(瀑布式事件) | +| [ACP 快照回放](../../../../packages/ui/acp/snapshot-replay.md) | 人工维护 | 快照 harness 行为 | + +### 为什么由生成器拥有这些文档 + +package 拓扑留在 `gen-module-graph.ts`,工具-package 能力映射留在 `gen-tool-catalog.ts`,因为这些生成器已经拥有权威事实和新鲜度门禁。`gen-doc-graphs.ts` 拥有其余关系页面和索引。代价是人工维护的图表需要在 TypeScript 字符串块中编辑,而非直接编辑 Markdown。对首批交付而言这是可接受的,因为面向用户的产物仍是纯 Markdown/Mermaid;如果撰写体验比可重新生成更重要,未来可以将人工维护的页面拆分出来。 + +### 完整性守卫 + +混合生成的页面在其 manifest 陈旧时必须显式失败: + +- 模块图读取每个 package 的 `peerDependencies`,并按 `packages/<group>/<pkg>` 路径对 package 分组。 +- 工具目录通过启动采集已发布的工具,并从同一份 manifest(其完整性守卫已在检查)渲染 package/服务/副作用映射。 +- 能力 seam 图导入 Cordis 服务收集器,断言每个被发现的 harness `ctx.<key>` 都已在 `SERVICE_ROLES` 中分类,且每个已分类的 key 仍然存在。 +- 事件生产者/消费者矩阵标记为混合生成,因为 subagent 生命周期事件有意使用 `ctx.events.dispatch` 实现逐监听器隔离;这些动态边是显式覆盖而非无声遗漏。 +- `verify-mermaid` 用 Mermaid 自身的解析器解析仓库中每个 ` ```mermaid ` 围栏,因此语法错误会在本地和 CI 的 `doc-sync` 中失败,而不是在 GitHub 渲染时才显示为损坏的图表。 + +## 曾考虑的替代方案 + +已提交的图表使用 Mermaid,因为 GitHub 在 Markdown 中原生渲染它,且不引入新的文档构建依赖;密集的多对多数据(如事件生产者/消费者关系)则使用 Markdown 表格。**PlantUML、托管图表服务和生成的 SVG** 曾被考虑,但在 Mermaid 成为瓶颈之前有意不采用。 + +## 后果 + +- 维护者获得了拓扑、seam、事件流、生命周期、应用组合和快照行为的可视化入口。 +- SDK 用户获得了从用例到 package 组合的路径,而不仅仅是自底向上的 package 参考。 +- `doc-sync` 现在包含 `verify-doc-graphs` 和 `verify-mermaid`,因此关系图漂移和 Mermaid 语法错误与其他文档新鲜度门禁一同被捕获。 +- 未来的文件系统和钩子工作有了承载新复杂度的具体位置:文件系统应扩展能力文档和工具目录,钩子应扩展事件矩阵和工具执行流水线。 diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml new file mode 100644 index 0000000000..18408700a9 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-cordis-jsdoc-completeness-gate.md: 44a0ddce9c929deac3e03bb421aec1d5145e65ba +2026-07-04-cordis-jsdoc-completeness-gate.zh.md: 6fea7f96a62295bd37e778a0aadd1e9ee6c8f2b4 diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md index 42ab306bb0..44a0ddce9c 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -1,5 +1,7 @@ # RFC: JSDoc completeness gate for the cordis surface +English | [中文](2026-07-04-cordis-jsdoc-completeness-gate.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md new file mode 100644 index 0000000000..6fea7f96a6 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md @@ -0,0 +1,41 @@ +# RFC:Cordis 接口的 JSDoc 完整性门禁 + +Status: implemented + +[English](2026-07-04-cordis-jsdoc-completeness-gate.md) | 中文 + +## 问题 + +生成的 Cordis 目录已强制检查事件的 dispatch 模式,但未检查服务与事件契约的完整性。方法可以缺少描述,参数或返回值可以在跨插件 API 接口上不写文档——而这恰恰是 IDE 引导最重要的地方。 + +AGENTS.md 规则("每个导出都有解释语义的 JSDoc")只能靠评审以行文方式检查;本仓库的既定偏好是将不变式编码为机械门禁。"Cordis 服务函数与事件"这一范围有一个精确的机器定义,只有目录生成器知道:事件是 `declare module 'cordis'` 内 `interface Events` 的成员,服务接口是每个 `interface Context` 键所指向的类的公开方法。ESLint 规则看不到这层映射;生成器在每次运行时都会计算它。 + +## 决策 + +扩展 `scripts/gen-cordis-catalog.ts`——同一次遍历、同一个 `@mode` 先例——对其目录化的所有内容强制 JSDoc 完整性。`verify-cordis-catalog` 运行在 `doc-sync` 内部,CI 和 lefthook pre-push 钩子都已执行 `doc-sync`,因此门禁无需新增任何接线(质量门禁原则:单一真源)。 + +契约如下: + +- **事件**需要描述文字,并为每个**载荷参数**提供非空 `@param`。载荷参数是签名中承载事件数据的参数;`this` 接收者注解和尾部的 waterfall `next` 免检——`next` 是 dispatch 机制,其语义已由 `@mode waterfall` 标签(及其结构交叉检查)拥有,逐事件重述只是样板。对免检参数写文档是允许的;门禁只检查缺失。 +- **服务类**需要类级 JSDoc,每个公开方法需要描述文字、每个参数一个非空 `@param`,以及一个非空 `@returns`(除非标注的返回类型是 `void`/`Promise<void>`,此时 `@returns` 可选——解析时机可能值得记录——但从不强制要求)。 +- **陈旧标签报错**:`@param` 命名了一个不存在的参数即为违规,与 `@mode` 与签名矛盾的检查对称。标签描述必须非空;超出此范围的语义质量由评审负责。 +- **遍历可检查的显式性**:门禁是纯 AST 遍历(不使用类型检查器),因此服务方法必须显式标注返回类型(推断的返回类型无法分类),接口参数必须是简单标识符(解构模式没有名字供 `@param` 匹配)。 +- **违规聚合**为一条错误信息,列出所有违规项——修复时一次看到全部。此前快速失败的 `@mode` 检查也移入同一份聚合报告,消息文本不变。 + +这些标签**仅用于门禁强制**:`parseJsDoc` 现在在遇到第一个块标签时截止描述文字(标准 JSDoc 语义,同时也防止多行标签描述泄漏到目录中成为正文),因此 `@param`/`@returns` 永远不会改变渲染出的目录。 + +`packages/core/agent/tests/gen-cordis-catalog.spec.ts` 中的负向路径测试用合成 fixture(测试前置数据)驱动 `collectEvents`/`collectServices`,证明每个守卫都能触发且免检规则成立。撰写规则写在根 [AGENTS.md](../../../../AGENTS.md) 约定条目中,与 `@mode` 规则并列。 + +## 曾考虑的替代方案 + +- **ESLint 规则**:看不到范围的机器定义(哪些 `interface Events` 成员、哪些 `ctx.<key>` 类构成 Cordis 接口);目录生成器在每次运行时恰好计算这层映射,因此门禁放在那里。 +- **将标签渲染到目录中**:曾考虑将服务部分重构为逐方法条目,但有意推迟:方法文档的消费场景是源码 JSDoc 加 IDE 悬浮提示,目录保持索引定位。 +- **逃生标签**:不设。接口面小且经过策划(采纳时 12 个服务、57 个方法、27 个事件),重点在于检查不可豁免。 + +## 后果 + +- 新增事件或服务方法如果参数或返回值未写文档,就无法合入:生成器拒绝重新生成,`verify-cordis-catalog` 在 pre-push 和 CI 中失败。采纳时发现的约 139 处缺口在同一个变更中补齐,门禁以绿色状态落地。 +- 服务接口必须显式标注返回类型并使用标识符参数。两项约束在采纳时均未构成负担(所有方法已有标注;不存在解构的 seam 参数);二者现在都是承重要求,违反时会被机械发现。 +- AGENTS.md 的通用 JSDoc 规则("一行能说清就写一行")在此接口上获得一条更严格的特例:只有当方法无参数且返回 void 时,一行摘要才仍然足够。 +- 对 `next` 或 `this` 写 `@param` 合法但不检查——这是有意的不对称:门禁强制载荷契约,拒绝索要样板。 +- 标签不改变渲染出的目录(正文在第一个块标签处截止)。如果日后需要方法级渲染,那是目录设计的独立决策,不是本门禁的缺口。 diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml new file mode 100644 index 0000000000..5d1764cea6 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-doc-tiers-and-budgets.md: ca9da847849f61f2fb244932e657cca8fec69696 +2026-07-04-doc-tiers-and-budgets.zh.md: 37447d11de181a007bcea23e29084aefd97b1ab5 diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md index 3f88e43c1d..ca9da84784 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -1,5 +1,7 @@ # RFC: Documentation tiers, budgets, and the ceiling gate +English | [中文](2026-07-04-doc-tiers-and-budgets.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md new file mode 100644 index 0000000000..37447d11de --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -0,0 +1,28 @@ +# RFC:文档分层、预算与上限门禁 + +Status: implemented + +[English](2026-07-04-doc-tiers-and-budgets.md) | 中文 + +## 问题 + +尽管已有写作指导,常设文档仍然积累了重复的规则、重述的事件、重复的 package 地图和陈旧的 RFC 摘要。由于仅靠评审无法阻止这种膨胀,仓库需要在文档分类体系之外再加一道机械化的预算。 + +## 决策 + +- **分层分类体系,每条事实只有一个归属。** [docs/AGENTS.md](../../../AGENTS.md) 是文档标准:它为每个 Markdown 层级指定唯一职责(常设指令、系统地图、类型目录、决策记录、事件故事、实操手册(cookbook)、package 契约、生成目录、工作流),禁止在归属层级之外重述事实(应改为链接),并附带一份在撰写或评审任何文档时使用的冗余检查清单。 +- **窄范围、硬约束的预算门禁。** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 加入 doc-sync:凡列入 [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 的文档都必须低于其字数上限(`wc -w` 语义,整个文件),且已设预算的文件若缺失也会使门禁失败,防止重命名时预算被静默遗留。范围有意仅限于容易膨胀的常设文档:根目录与子树的 `AGENTS.md`、`architecture.md`、`packages/README.md`,以及它们将内容分流到的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、RFC 和 package README 不设预算:当每一行都是事实时,长度是合理的,由评审加冗余检查清单管控。 +- **上限是只进不退的执行红线。** 上限设定在文档当前大小的至少 5% 以上(留出操作余量,使日常措辞修改不会触发门禁,而真正的膨胀仍会被拦截),并随着文档被压缩到目标预算(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)而保持该余量向下收紧——与[翻译配对 `required` 清单](2026-07-02-bilingual-docs-and-pairing-gate.md)的推进机制相同。门禁变红时,修复方式是按分类体系迁移或精简内容;只有在 PR 描述中给出明确理由时才允许提高上限,manifest diff 本身即为可评审的动作。 +- **轻量工作流 skill,契约在文档中。** [.agents/skills/dsh-doc-standards](../../../../.agents/skills/dsh-doc-standards/SKILL.md) 承载归位/审计/红灯修复工作流,并将文档标准作为真源——与 [dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md) 对 i18n 契约的分工方式相同。 + +## 曾考虑的替代方案 + +- **仅靠 skill 与评审纪律,不设门禁**:否决。上述膨胀正是在既有的现状规则和评审者注意力下发生的;一条没有机械后盾的行文规则在这里已被证明守不住,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.md)说的是:值得保持的不变式就值得编码。 +- **对所有文档层级设置宽泛门禁**:否决。一刀切的上限恰恰惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实,例如 `packages/ui/acp/acp-feature-support.md`),并产生逐文件的例外修改,训练贡献者无脑批准上调。 +- **将标准放在 skill 内部**:否决。契约放在文档中,工作流放在 skill 中;如果标准被塞进 SKILL.md,那些不调用该 skill 而直接编辑文档的 agent 就看不到它,而 `docs/AGENTS.md` 已经作为子树指令被加载给所有在 `docs/` 下工作的人。 + +## 后果 + +- 向已设预算的文档添加内容现在需要置换:将新增内容迁移到其分类归属处并留下指针,或精简既有行文为其腾出空间。只增不减会导致 CI 失败。 +- 将文档压缩到目标预算的重写以堆叠的后续 PR 落地,每个合并时都将 manifest 中的上限向下收紧;在各自落地之前,文档的冻结上限仅阻止进一步膨胀。 +- 字数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它恰好在内容被添加的那一刻强制触发迁移决策——而那正是作者拥有足够上下文来正确归位内容的时刻。 diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml new file mode 100644 index 0000000000..814ca38546 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-generate-rfc-index-tables.md: 6a8888eda8b7cf7105a44802774bf49d6463952d +2026-07-04-generate-rfc-index-tables.zh.md: 42ae64306aa1d5cf9117ca2b4d698a5d8effdeec diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md index d854991ad9..6a8888eda8 100644 --- a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md @@ -1,5 +1,7 @@ # RFC: Generate the RFC index tables +English | [中文](2026-07-04-generate-rfc-index-tables.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md new file mode 100644 index 0000000000..42ae64306a --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md @@ -0,0 +1,34 @@ +# RFC:生成 RFC 索引表 + +Status: implemented + +[English](2026-07-04-generate-rfc-index-tables.md) | 中文 + +## 问题 + +RFC 索引中按生命周期/按类别的表格所列信息完全可推导:RFC 的路径编码了生命周期与类别,文件名编码了首次提出日期,H1 标题即为标题。手工维护这些事实的副本恰恰是本仓库文档中冲突最频繁的热点:每一波提案都向同几行追加行,因此并行的 RFC 分支恰好在此处冲突,而在其他所有地方都没有分歧;每次冲突都要手动合并那些文件系统本已知晓内容的行。[分类 RFC](2026-06-20-rfc-classification.md) 最初为了策展目的保留手写索引,但 README 中真正需要策展的部分是行文,而行文从不冲突;冲突的只有机械表格。 + +## 决策 + +保留策展行文;生成列表。表格位于 [`docs/rfc/INDEX.md`](../../INDEX.md),是一个**完全生成的文件**;策展行文留在 README.md 中,README.md 不包含任何索引行。[`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) 是共享的真源:树遍历器(拥有封闭的生命周期/类别集合与结构规则,包括 H1 可解析的要求)和渲染器(行来自 H1 标题并去除 `RFC: ` 前缀,加上文件名日期,按日期再按文件名排序,以 `### {Class}` 分节、按规范类别顺序分组)。两个轻量消费方共享它: + +- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts)(`pnpm run gen-rfc-index`)从目录树完整重写 INDEX.md。 +- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts)(doc-sync 的一个成员)检查结构,断言已提交的 INDEX.md 与新鲜渲染结果逐字节一致(与 `gen-cordis-catalog`/`verify-cordis-catalog` 模式相同),并拒绝在策展 README 中出现索引格式的行。新鲜度检查涵盖了索引完整性检查:从磁盘生成的表格在定义上就是完整且标题正确的。 + +添加、移动或删除一个 RFC 只需编辑 RFC 文件本身并运行生成器;分类 RFC 的「否决替代方案」记录中带有取代关系的交叉链接。 + +## 曾考虑的替代方案 + +### 为什么不在 README.md 内使用标记分隔区域? + +最初落地的形态:生成器在 README.md 中的 `gen-rfc-index` 标记注释之间、每个 `## {Lifecycle}` 标题下拼接表格。在 README 同时吸收了文件内格式契约([统一格式 RFC](2026-07-05-uniform-rfc-format.md))之后,被整文件 INDEX.md 方案取代:一个门面 README 承载数百行生成行,会淹没其策展行文;而拼接机制(标记对、标题检查、区域外行检测)的存在只是为了保护策展文本——专用的生成文件根本不包含策展文本。 + +### 为什么不采用纯校验模式? + +纯校验能捕获错误,但每次提案编辑仍然要在手工维护的表格中触碰共享热点;对于一行纯机械内容,校验失败比生成器更令人烦恼:作者已经命名并放置了文件,索引副本不增加任何信息。这与 [package-inventory 提案](../../proposed/process/2026-06-20-discover-package-inventory.md) 对 tsconfig references 和 knip stanzas 所做的「手工列表 vs. 推导」判断相同——应用于这张确实会冲突的列表。 + +## 后果 + +- 生成文件是显式的:其横幅标注了生成器名称,文件内没有需要保护的策展区域,且生成器在目录树结构无效时拒绝运行。 +- 格式错误或缺失的 H1 在生成器和门禁中都是硬错误:H1 现在是承重的,它是索引标题的来源。 +- 并行的 RFC 分支通过重新运行生成器来解决索引冲突,而非手动合并行。 diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml new file mode 100644 index 0000000000..d824b18b5d --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-persistence-log-catalog.md: f8f831ca0470cf3b5c7634550115e67f7deac840 +2026-07-04-persistence-log-catalog.zh.md: d8cd5f74e24968fa2ad01f128dafe8c867f3406c diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md index 0de6255322..f8f831ca04 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -1,5 +1,7 @@ # RFC: Generated persistence log event catalog +English | [中文](2026-07-04-persistence-log-catalog.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md new file mode 100644 index 0000000000..d8cd5f74e2 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md @@ -0,0 +1,36 @@ +# RFC:生成式持久化日志事件目录 + +Status: implemented + +[English](2026-07-04-persistence-log-catalog.md) | 中文 + +## 问题 + +`SessionEventMap` 是磁盘上的词汇(vocabulary),但其声明分散在所属的 session 包与声明合并中。生成式持久化目录是每个事件及其 payload 的唯一参考;手工维护的表格会漂移,已被移除。这些记录不是 Cordis 事件:观察者通过唯一的 `session/event` 总线事件接收它们,因此 Cordis 目录无法覆盖。生成器会发现所有声明,doc-sync(文档同步门禁)新鲜度门禁会拒绝遗漏或陈旧的输出。 + +## 决策 + +从源码生成 `docs/persistence-catalog.md`,配以新鲜度门禁,作为第四个参考面:持久化会话日志可以包含的*记录*,与 Cordis 目录(接线)、核心数据结构(词汇)和工具目录(工具)互补。 + +`gen-persistence-catalog.ts` 使用 TypeScript AST 扫描所有所属的和声明合并的 `SessionEventMap`。它渲染源码 JSDoc、payload 类型、派生的 surface 徽章、参考链接和源码位置。doc-sync 新鲜度检查会拒绝任何词汇变更后未重新生成目录的情况。 + +具体选择: + +- **JSDoc 完整性,强制执行。** 每个成员必须携带描述性文字:JSDoc 即为目录条目,与 Cordis 目录对总线事件施加的强制函数相同。成员上的 `@mode` 标签是硬错误:dispatch mode 属于 Cordis 总线事件,日志事件没有 mode;该标签会被误读为「此事件以 mode X 在总线上触发」。违规项聚合为一条错误,列出所有违规者。 +- **surface 徽章由派生得出,而非手工列举。** `SurfaceEventType`(产生 LLM 消息且可能携带 `surfaceOp` 的子集)从所属包中的 union 声明解析而来;union 成员如果命名了一个未声明的事件,则为硬错误(否则一个陈旧的 union 成员会静默地不标注任何事件)。其余一律渲染为 **log-only**。 +- **专用围栏。** payload 块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 识别并跳过它,不计入 opt-out 比例——与 `ts cordis-catalog` 的处理方式相同(裸 payload 片段不能独立编译)。 +- **仓库范围。** 目录枚举本仓库中的包,与兄弟目录的 packages-only 范围一致;下游插件可以合并更多事件类型,但它们在设计上不在目录范围内。遍历过程用硬错误保护自身假设:所属的顶层 `interface SessionEventMap` 必须是 `@deepseek-ai/dsh-session` 中唯一的导出声明(一个无关的、局部的或重复的同名接口不能被当作磁盘词汇编入目录);任何声明不得携带 `extends`(继承的键会加入 `keyof SessionEventMap` 却没有对应的目录行);每个成员必须是带有显式 payload 类型的属性签名(方法形式的成员会加入 `keyof` 却被静默遍历跳过);跨声明的重复成员会失败。 + +这取代了手工副本:session.md 的 `hook/*` 表格、compact README 的事件表格、hook-protocol README 的 payload 列表,以及 session README 的名称列表现在链接到目录,而非重述 payload(周围的语义行文保留原位)。hook-protocol 合并成员上两个多余的 `@mode emit` 标签已被移除——新门禁将其拒绝为它们本来就是的类别错误。 + +## 曾考虑的替代方案 + +- **基于启动的生成器(如工具目录的方式)**:日志词汇完全是静态的,AST 遍历无需启动任何东西即可读取全部真相。 +- **保留手工副本**:手工副本只能检查作者已经写下的名称;目录落地时 session README 的合并说明已经漂移。 + +## 后果 + +- 目录不可能漂移:词汇变更而已提交文件未反映的,`verify-persistence-catalog` 在 pre-push 钩子和 CI 中会失败;新合并的事件如果没有 JSDoc,生成器直接报错——插件不能再添加未文档化的磁盘记录类型。 +- 事件描述有唯一归属地:声明处的 JSDoc。JSDoc 写得薄,目录条目就薄,这对作者形成在源头写文档的压力。 +- `SurfaceEventType` union 现在对文档具有结构性承载作用:重命名事件而不更新 union(或反过来)会导致生成器失败,而不仅仅是编译器失败。 +- 徽章派生假设 union 始终是一组封闭的字符串字面量且只有一个所有者;如果重构偏离了这一形状,必须在同一个变更中更新生成器。 diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml new file mode 100644 index 0000000000..bb326da2d8 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-05-uniform-rfc-format.md: f67c61c0b627950cf07e7d57672324308a9462ec +2026-07-05-uniform-rfc-format.zh.md: a11cba1c343f150a67c1af4a04a8d89480185207 diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md index d5ce28066d..f67c61c0b6 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md @@ -1,5 +1,7 @@ # RFC: One gated in-file format for RFCs +English | [中文](2026-07-05-uniform-rfc-format.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md new file mode 100644 index 0000000000..a11cba1c34 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md @@ -0,0 +1,30 @@ +# RFC:为 RFC 统一一种有门禁保障的文件内格式 + +Status: implemented + +[English](2026-07-05-uniform-rfc-format.md) | 中文 + +## 问题 + +RFC 的路径已编码了生命周期与分类,但文件内容仍然混杂着不同的标题风格、状态格式、ADR 模板与提案模板,以及已实施记录中残留的提案时期章节。作者复制手边找到的任何邻居文件作为模板,而生命周期迁移可以跳过必要的改写,因为没有门禁强制执行文件内契约。 + +## 决策 + +[README.md § The file format](../../README.md#the-file-format) 即为文件内契约:头部块(`# RFC: <title>` 加上不含日期、与所在文件夹一致的 `Status:` 枚举,其唯一内容是否决原因);按生命周期区分的正文骨架(所有阶段都以 `Problem` 开头;`proposed/` 中使用 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 中使用现在时的 `Decision`/`Consequences` 且禁止提案时期标题;`rejected/` 中冻结提案形态);强制的 `Alternatives considered` 章节;以及规范的章节词汇表——在这些固定章节之间,自定义的技术章节保持自由格式。`pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts))作为 doc-sync(文档同步门禁)的一环强制执行每一条机械化条款,因此跳过改写的生命周期迁移现在会导致 CI 失败,而非依赖评审者的记忆。 + +整个语料库在定义格式的同一个变更中完成了规范化——这是预发布阶段的立场:不设过渡期,不容忍双格式并存。唯一的祖父条款针对内容而非格式:替代方案只记录已有的,不凭空编造;因此如果一篇格式定义之前的 RFC 的替代方案无法从记录中重建,它会携带 `rfc-format: alternatives-not-recorded` 注释,门禁仅对日期早于本 RFC 的文件接受该注释。 + +## 曾考虑的替代方案 + +- **完全刚性模板**(每个生命周期一套固定章节顺序,所有 RFC 重构以适配):否决。大型设计 RFC 携带八到十五个自定义技术章节(包拓扑、协议格式契约、schema),这些是承重内容而非漂移;刚性顺序会迫使当下进行破坏性改写,并永远与模板对抗。 +- **仅规范化头部**(H1 与 Status,正文不动):否决。技术债标记指出的正是*正文*的体裁分裂,让 `Context`/`Decision` 与 `Problem`/`Proposal` 无限期并存什么也解决不了。 +- **不设 Status 行**(文件夹本身就是状态;三篇最新的格式定义前 RFC(及其中一篇的中文对侧文件)省略了该行):否决,保留自描述文件。当初促使去掉该行的漂移风险,已被「门禁将该行与文件夹做一致性校验」所消除。 +- **带日期的状态**(`Status: implemented (accepted YYYY-MM-DD)`):否决。接受日期属于叙述性历史,写作规则将其排除在文档之外;文件名承载首次提出日期,git 承载其余信息,门禁能检查日期格式但永远无法检查其真实性。 +- **裸 `# <title>` H1**:否决。`RFC: ` 前缀是语料库中的多数形式,且在文件脱离目录树阅读时能自描述体裁;索引生成器会剥离它,因此索引行无论哪种写法都一样。 +- **`## What we give up` 作为已实施记录的收尾章节**(README 自身用来描述 RFC 所记录内容的措辞):否决。它只命名了代价,而诚实的后果章节同时记录权衡所换来的收益。 +- **约定而无门禁**(写下契约,靠评审强制执行):否决。slop checklist 已通过约定禁止在 `implemented/` 中使用规范体措辞,而十九个文件展示了纯靠约定在这里能达到什么效果。 +- **独立的 `FORMAT.md` 契约文件**:最初落在此处;在生成索引迁出至 [INDEX.md](../../INDEX.md) 后折入 README.md:表格移走后 README 重新有了空间,一个前门同时承载布局、分类与格式,优于将契约拆分到两个文件。 + +## 后果 + +每篇 RFC 现在多了少许结构成本,而强制的 `Alternatives considered` 章节是有意为之的摩擦:一个不记录被否决方案的决策,会招来 RFC 本应防止的反复讨论。格式定义前的 RFC 若其替代方案无法重建,则永久携带祖父条款注释——这是记录上的诚实空白,而非编造的理由。doc-sync 新增一道门禁,在生命周期文件夹之间迁移 RFC 现在是迁移时的实际工作(即迁移本就欠下的正文改写),而非无人追踪的延后清理。三十九个技术债标记已全部消除,由它们等待的模板所解决。 diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml new file mode 100644 index 0000000000..dd08d60974 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-export-surface-jsdoc-gate.md: fd255cc6212d7f6f919ad23f999fa68b01478a73 +2026-07-06-export-surface-jsdoc-gate.zh.md: 02a5c2682095c66818a51cc14fd565111b7e6e80 diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index 6ebb477dea..fd255cc621 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -1,5 +1,7 @@ # RFC: Export-surface JSDoc gate +English | [中文](2026-07-06-export-surface-jsdoc-gate.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md new file mode 100644 index 0000000000..02a5c26820 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md @@ -0,0 +1,45 @@ +# RFC:导出表面 JSDoc 门禁 + +Status: implemented + +[English](2026-07-06-export-surface-jsdoc-gate.md) | 中文 + +## 问题 + +[cordis JSDoc 完整性门禁](2026-07-04-cordis-jsdoc-completeness-gate.md)使 cordis 表面上的未文档化参数和返回值不再可能——`interface Events` 成员与 `ctx.<key>` 服务类——但那只是插件作者所导入内容的一小部分。AGENTS.md 中「每个导出(及非显而易见的方法)都应有 JSDoc 说明语义」这条规则在其他地方仍然只能靠评审以行文方式检查,而且没有任何机制要求普通导出函数带 `@param`/`@returns`。采纳时的一次调查发现 34 个包中有 203 个文档不完整的模块级导出:seam 相关辅助函数(`runBash`、`readForEdit`、`htmlToMarkdown`)、格式编解码器、整个未文档化的接口和类型别名——正是 IDE 消费方悬停时看到的那些名字。 + +## 决策 + +新增门禁 `scripts/verify-export-jsdoc.ts`(`pnpm run verify-export-jsdoc`,接入 doc-sync,与 `verify-cordis-catalog` 并列),遍历每个 `packages/<group>/<pkg>/src/` 目录树下所有模块级导出名。解析与检查辅助函数从 `gen-cordis-catalog.ts` 移入共享的 `scripts/jsdoc.ts`,因此「已文档化」在两个表面上含义一致:描述文本在第一个块标签处截止,每个可检查参数需要非空 `@param`,非 void 的**已标注**返回值需要非空 `@returns`,过时的 `@param` 报错,违规汇总为一份报告。 + +按声明类型划分的契约: + +- 每个导出名都需要带有非空描述文本的 JSDoc。 +- 函数类导出(函数声明;以函数初始化器或行内可调用标注的 const;非标识符的函数默认导出)遵循完整的函数契约,分类前会剥离包装表达式(括号、`as`/`satisfies` 转型、非空断言)。如果 const 的声明器标注了一个**命名**类型(`export const f: Handler = …`),则签名契约推迟到该类型自身的声明,`@returns` 可选;行内 `(x: T) => U` 标注或单调用签名字面量即为表面签名本身,适用完整契约;而字面量中混合了调用/构造签名与其他成员的情况则直接拒绝(没有单一签名可供标签对照——请提取命名类型)。 +- 导出类需要类级别的描述文本;公开方法(包括静态方法——可通过导出名访问)遵循函数契约;公开属性和访问器需要描述文本(get/set 对由 getter 覆盖)。重载实现免检——由签名承载文档。 +- 导出的接口、类型别名和枚举需要声明级别的描述文本;成员级别的强制有意推迟(承载关键成员契约的 seam 服务类已在 cordis 门禁下)。 +- 导出的命名空间递归检查(在 ambient `declare` 命名空间内,每个成员隐式导出);命名空间本身仅在不与同名已文档化声明合并时才需要描述文本(Config 命名空间惯用法只需文档化插件一次)。 +- `declare module` / `declare global` 体和 `export … from` 再导出语句被跳过:augmentation 不是包的导出,再导出的定义在其定义处检查。`export import X = N.member` 别名文档化**自身**——其目标可能是遍历不会访问的非导出命名空间成员——且仅支持纯描述文本的目标类型:可调用、类或命名空间目标携带别名描述文本无法承载的签名/成员契约,门禁拒绝此类情况并要求直接导出该声明。 +- 其余一切按**封闭**原则失败:`export =` 直接拒绝;基类从未命名的参数即使作为绑定模式仍保留 `@param` 义务;调度未识别的导出语句类型本身即为违规——没有任何导出形式能因遗漏而免检。 + +三类豁免避免门禁要求样板代码,精神与 cordis 门禁的 `this`/`next` 豁免一致(对已豁免的名字主动写文档是允许的;只有缺失才不被检查): + +- **继承成员。**重写从基类声明继承文档。新增的公开表面仍需文档:新增参数、将 protected 成员公开重写、或在 void 基类之上给出具体返回值。继承查找与推断返回值分类是门禁唯一需要类型检查器的工作;其他检查使用 AST。 +- **插件协议槽位。**顶层 `name` / `inject` / `reusable` / `Config` const 与 `apply` 入口,以及插件类上作为静态成员的相同槽位,属于框架协议:其形状由 cordis 固定,模块文档注释加 `interface Config` 承载插件的真实语义。 +- **构造函数**,与 cordis 门禁一致:插件类由框架构造,类文档承载全部说明。 + +`collectExportJsdocViolations()` 返回违规列表(CLI 在非空时以 exit 1 退出),因此 `packages/core/agent/tests/verify-export-jsdoc.spec.ts` 中的负路径测试直接对发现结果断言,通过 fixture 包驱动每一种拒绝和每一种豁免。 + +## 曾考虑的替代方案 + +- **eslint-plugin-jsdoc**(`require-jsdoc`/`require-param`/`require-returns`):覆盖了机械核心,但无法表达本仓库的契约:继承成员豁免需要跨包类型解析,协议槽位和命名空间合并惯用法是 cordis 特有的,而完整性语义(标签前描述文本、过时标签报错、聚合报告)已在 `scripts/jsdoc.ts` 中与 catalog 生成器共享一处。两套微妙不同的「已文档化」定义正是本仓库「一处为家」规则要防止的失败模式。 +- **扩展 `gen-cordis-catalog.ts`**:catalog 生成器渲染一个精选表面并门禁其新鲜度(freshness);仓库级遍历没有 catalog 可渲染。共享辅助函数但保持遍历分离,使每个门禁的职责清晰可读。 +- **强制接口/类型别名的成员文档**:推迟。这会将检查表面扩大到大量自描述字段,而承载关键成员契约的 seam 类已在门禁下。如果评审中出现成员文档漂移再重新考虑。 + +## 后果 + +- 新导出不能在无文档的情况下落地:`verify-export-jsdoc` 使 doc-sync 失败,而 pre-push 和 CI 已经运行 doc-sync。采纳时发现的 203 处缺口在同一个变更中补齐,因此门禁以绿色状态落地。 +- 导出函数必须标注返回类型(采纳时已全面覆盖,现在成为承载性要求),且在 `@param` 需要命名的地方使用标识符参数。 +- seam 文档是权威的:实现从继承链继承文档,值得保留在实现上的行为说明是补充,而非必需。 +- 门禁构建一个 `ts.Program`(约 6 秒)——唯一需要类型解析的文档门禁;在已经编译文档片段的 doc-sync 中可以接受。 +- 协议槽位名在模块顶层按约定保留;一个碰巧名为 `apply` 或 `Config` 的非协议导出会免检——已接受,记录于此。 diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml new file mode 100644 index 0000000000..394c2494ac --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-generated-config-catalog.md: 50ba0dc70b0d8ea54a4f93911f3a087806774626 +2026-07-06-generated-config-catalog.zh.md: 2dd0cbe5303f08aa2f3300c6f8613a7823c2bc78 diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md index 999ebd8503..50ba0dc70b 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md @@ -1,5 +1,7 @@ # RFC: Generated plugin config catalog +English | [中文](2026-07-06-generated-config-catalog.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md new file mode 100644 index 0000000000..2dd0cbe530 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md @@ -0,0 +1,40 @@ +# RFC:生成式插件配置目录 + +Status: implemented + +[English](2026-07-06-generated-config-catalog.md) | 中文 + +## 问题 + +仓库此前没有以源码为后盾的插件配置参考。各 package 的 README 对字段的记录方式不一致,没有列举哪些包可被加载,也没有校验运行时 schema 是否与声明的配置类型一致。 + +## 决策 + +`scripts/gen-config-catalog.ts` 从每个插件声明的配置类型与 JSDoc 生成 [docs/config-catalog.md](../../../config-catalog.md),包含注入要求、引用类型链接和源码指针。package 内部类型被传递性地包含;workspace 和外部类型以链接或名称引用。确定性的 `--write` 和 `--check` 模式使提交到仓库的页面成为一个生成产物(artifact)。 + +此处采用纯 AST 生成是正确的,原因与事件/服务目录相同,也与工具目录不同:配置类型是静态声明,仓库中每个 schemastery schema 都是静态的 `z.object`/`z.intersect` 字面量,因此源码就是全部真相——配置表面没有任何部分是运行时组合的。 + +具体选择: + +- **配置类型取自第二参数类型。** 目录记录的是 `apply(ctx, config)` / 服务构造函数 `(ctx, config)` 的声明参数类型——即 Cordis 实际传入的值——而非按命名约定定位的 `Config` 导出。这使得遍历是全量的:无论接口名为 `AcpConfig` 还是 `BasicCompactConfig`,无论类型声明在兄弟文件中,还是插件根本没有校验 schema,都能正常工作。 +- **分类是全量的。** 每个 `packages/<group>/<pkg>` 条目都会被解析(镜像 Loader 的 `unwrapExports`(`exports.default ?? exports`)),归入以下之一:可配置插件、无配置插件、抽象 seam 类或库——各自渲染在独立章节中——无法归类的条目会硬错误。新 package 不可能被静默地遗漏。 +- **逐字段 JSDoc 强制要求。** 粘贴的声明中每个属性(包括嵌套的类型字面量)都需要非空的 JSDoc 描述,否则生成失败。粘贴本身就是文档,因此这与事件目录通过 `@mode` 施加的强制函数相同:源码文档不足时门禁失败,而非产出一份单薄的目录。 +- **Schema 键与声明类型交叉检查。** 生成器通过本地和 workspace 类型解析嵌套的对象与数组路径。确定缺失的路径会失败;无法枚举的外部或动态形状则跳过。检查有意设计为单向的,因为声明类型可能包含从 loader 配置中排除的运行时专用字段。 +- **专用围栏。** 粘贴的声明使用 ` ```ts config-catalog ` 信息字符串,`doc-typecheck` 会跳过它(引用了导入类型的孤立声明无法独立编译),并将其排除在 opt-out 比例之外——与 `cordis-catalog` 和 `persistence-catalog` 围栏的处理方式相同。 +- **单文件 `docs/config-catalog.md`**,而非一个单文件目录:该页面服务于单一受众(`cordis.yml` 的编写者),只有一个维度,不同于 `cordis-catalog/`(它包含两个并列页面)。 + +各 package README 的 `## Config` 章节保留。这种重叠是有意接受的:README 是精心策划的逐 package 契约(部署上下文中的配置语义,连同限制与扩展点),目录则是穷举式的生成枚举。因为目录是生成的,二者之间的分歧说明 README 有误,修复方式是编辑 README——目录不会漂移。 + +## 曾考虑的替代方案 + +- **合成式逐字段渲染**:为每个字段生成一个项目符号列表、表格或带注释的 YAML 片段,由解析后的 JSDoc 加 schema 元数据组装。否决,改用逐字粘贴:带 JSDoc 的接口本身就是以其原始形式撰写的契约,合成渲染器会重新格式化它不拥有的行文,增加一层可能歪曲原意的渲染。 +- **运行时启动 + schema 内省(如工具目录的做法)**:否决。此处没有任何内容是运行时组合的,而且 schema 本身对配置表面的文档化不足(行文记录的默认值、运行时专用字段、完全没有 schema 的插件)。启动只会增加脆弱性而不增加真相。 +- **双向 schema/接口相等性检查**:否决,改用子集检查。声明类型合理地包含 schema 拒绝从配置接受的成员(运行时专用的 seam)。 +- **在同一变更中废弃 README 的 `## Config` 章节**:否决。接受的重叠使逐 package 契约在原地可读,而一次清扫需要先把每个 README 的额外事实折入字段 JSDoc——这是可分离的工作,目录不依赖它。 + +## 后果 + +- 目录不会漂移:源码变化而提交的文件未反映时,`verify-config-catalog` 在 pre-push 和 CI 中失败。未记录的配置字段、无法解析的引用类型名称、或 schema 键在配置类型中缺失,都会导致生成器直接报错。 +- 配置行文现在在声明处有了强制函数:编写新的配置字段意味着编写其 JSDoc,而 JSDoc 会逐字成为目录条目。 +- 生成器对无法静态遍历的形状硬错误——别名化的 package 内部配置导入、非 `object`/`intersect` 组合构建的 schema、未列入的全局类型名。引入这样的形状就必须同时教会生成器(否则该形状不能进入仓库),这正是设计意图:目录始终是全部真相。 +- `gen-cordis-catalog.ts` 导出其 JSDoc/指针辅助函数与 `LINK_MAP` 供复用,因此两个目录以相同方式交叉链接类型,新增一条 link-map 条目同时服务于两者。 diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml new file mode 100644 index 0000000000..d7db5cac08 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-node-engine-floor.md: 561b6b4a124b6eaa8e2ba0756a835e35519b30b8 +2026-07-06-node-engine-floor.zh.md: c6ace7a1296b5e3049ff4ef55f29b689fce7f4ff diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md index af08c96be3..561b6b4a12 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md @@ -1,5 +1,7 @@ # RFC: Raise the Node LTS engine floor to 22.19 +English | [中文](2026-07-06-node-engine-floor.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md new file mode 100644 index 0000000000..c6ace7a129 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -0,0 +1,39 @@ +# RFC:将 Node LTS 引擎下限提升至 22.19 + +Status: implemented + +[English](2026-07-06-node-engine-floor.md) | 中文 + +## 问题 + +根 `engines.node` 范围中的 Node 22 分支是对已安装工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。该分支的下限不得低于工作区在该分支上安装的依赖所声明的 package `engines.node`;否则 `pnpm install --engine-strict` 会在一个被宣传的 LTS 版本上失败,而非严格模式的安装则会在依赖所支持的运行时范围之外运行。 + +## 决策 + +将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每个矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限同时通过完整的源码类型检查和真实的未构建运行时路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上运行,因为它验证的是 API 集成而非运行时下限。 + +两项 Node 特性决定了源码运行时的下限: + +- **`node:sqlite`**:`packages/session-persistence/session-persistence-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` flag 要求;在此之前,导入它会在加载时抛出异常。 +- **原生 TypeScript 类型剥离**:`packages/examples/stdio-demo/tests/built-bin.e2e.ts` 冒烟测试在纯 `node`(无 tsx)下启动已发布的 `lib/bin.js`,并加载示例的 `.ts` 插件(`mock-llm.ts`、`echo-tool.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;在此之前需要 `--experimental-strip-types`。 + +这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步抬高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的 package 声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不连续范围完全排除 Node 23:Node 23.0–23.5 仍有至少一项源码特性需要 flag,而 23 线是非 LTS/已 EOL,宣传 `>=23.6` 只会增加一个已死的发布线和一个不应被任何部署使用的 CI 分支。 + +`@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:如果使用了 Node 23+/24+/25+ 才有的 API,`tsc` 会在所有机器和类型检查门禁中报错,而不是编译通过后存活到只有下限矩阵分支才能捕获的运行时失败。整棵树目前在 Node 22 类型表面上类型检查全部通过,因此这个固定没有代价。 + +## 后果 + +- 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。 +- CI 用 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每个分支都对源码图做类型检查,并实际启动未构建的工作流 worker。 +- built-bin 冒烟测试不需要版本条件 flag:在 22.19 上类型剥离已是默认行为,因此测试保持其文档记录的纯 `node lib/bin.js` 路径。 +- 未来如有依赖或源码 API 抬高运行时下限,必须在同一个变更中同步修改 `engines.node`、兼容性矩阵与本 RFC。 + +## 曾考虑的替代方案 + +- **保持 `^22.18.0 || >=24.0.0`。** 否决:它宣传的 LTS 版本低于 Pi 适配器依赖的下限。`@earendil-works/pi-ai@0.79.3` 要求 `>=22.19.0`。 +- **降级或固定 `@earendil-works/pi-ai` 以保留 22.18 的宣传范围。** 否决:当前的 Pi 适配器依赖是工作区的预期组成部分,且 22.19 仍在 Node 22 LTS 线内。 +- **下限设为 `>=22.13`(`node:sqlite` 边界),在 22.13–22.17 的 built-bin 冒烟测试中加 `--experimental-strip-types`。** 否决:为一个窄范围增加版本条件测试 flag,并将对实验性 flag 的依赖伪装成正式支持。Pi 适配器依赖已经要求更高的 LTS 下限。 +- **开放式 `>=22.19`。** 否决:它宣传支持 Node 23.0–23.5,而在这些版本上 `node:sqlite`(直到 23.4)或类型剥离(直到 23.6)仍需 flag。 +- **包含 Node 23.6+(`^22.19.0 || >=23.6.0`)。** 否决:23.6+ 确实能无 flag 运行两项源码特性,但 Node 23 已 end-of-life;宣传一个已死的发布线只会增加一个范围项和一个 CI 分支,用于一个不应被任何部署使用的运行时。 +- **矩阵用 `[22, 24, 26]` 而非固定 `22.19`。** 否决:浮动的主版本号条目会随时间上漂,悄然不再验证所声明的 LTS 下限。 +- **让 `@types/node` 超前于下限(`^25`)。** 否决:类型定义超前于运行时下限会让仅 Node 24/25 才有的 API 编译通过,仅在 22.x 上运行时才失败。将 `@types/node` 固定在 22.x 线上,会把这种情况变成所有环境下的编译错误。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml new file mode 100644 index 0000000000..81369745d3 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-parallel-github-ci-gates.md: 890adf58ac8a20a39806aa028d035cb253d5a4f1 +2026-07-06-parallel-github-ci-gates.zh.md: 02502b1005a1f8e6f9f539878c792a9f0585e926 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md index be31a439c1..890adf58ac 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -1,5 +1,7 @@ # RFC: Parallel GitHub CI gates +English | [中文](2026-07-06-parallel-github-ci-gates.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md new file mode 100644 index 0000000000..02502b1005 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md @@ -0,0 +1,41 @@ +# RFC:并行 GitHub CI 门禁 + +Status: implemented + +[English](2026-07-06-parallel-github-ci-gates.md) | 中文 + +## 问题 + +keyless GitHub CI 门禁大多彼此正交:类型检查、lint、文档新鲜度、覆盖率、快照回放、构建、包发布卫生检查、demo 冒烟测试和 built-bin 冒烟测试各自因不同原因失败,且不需要彼此的运行时状态。将它们串成一条有序命令链,工作流的挂钟时间等于所有门禁之和;而将每个叶子门禁拆成独立的 GitHub job,则会重复 checkout、Node 搭建、pnpm restore 和 install 工作,直到编排开销本身成为瓶颈。 + +难点在于产物边界。`publint`、`verify-node-next-types` 和 built-bin 冒烟测试需要构建出的 `lib/` 输出,而大多数门禁只需要源码和依赖。盲目扇出要么让这些产物消费方在 `pnpm run build` 输出声明文件和 bundle 之前就开始执行,要么在每个依赖产物的 job 中重复构建。 + +## 决策 + +[CI](../../../../.github/workflows/ci.yml) 将 keyless 检查分组为若干宽粒度的主运行时 lane,外加一个兼容性矩阵。工作流文件拥有当前 lane 和运行时清单的定义权。 + +每个 lane 委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),后者以有界并发调度独立门禁,并为每个门禁打印一个可归因的结果块。产物消费方在各自 lane 内依赖一次 build;兼容性 job 则将类型检查与一次真实的未构建 worker 启动相结合,以覆盖运行时特定的 loader 行为。 + +生成的 `.sessions/` 日志和 `.doc-typecheck-*` 临时目录被 lint 忽略。聚合的本地 CI 模式仍在 lint 之后运行 demo 冒烟测试;而拆分后的 GitHub 静态 lane 可以直接运行 demo 冒烟测试,因为 lint 已隔离在自己的 lane 中。 + +构建输出在 Node 24 产物 lane 中只生成一次。产物消费方(`publint`、`verify-node-next-types` 和 built-bin 冒烟测试)声明对 `build` 的依赖,因此没有 upload/download 交接,消费方也不可能抢在声明文件或 bundle 之前执行。CI 覆盖率报告仅为文本格式,本地覆盖率则保留 HTML 报告。 + +两个工作流都缓存 pnpm store。真实 API 工作流使用共享的有界 Vitest 文件池,而非为每组测试单独开 job。 + +## 曾考虑的替代方案 + +- **在 Node 矩阵中保留完整串行链**:最容易理解,但会重复执行不产生 Node 版本特定信号的仓库级门禁,且让每个 PR 等待所有门禁之和。 +- **每个门禁各开一个 GitHub job**:最大化 GitHub 可见的扇出,但产生过多 check,且对运行时间短于 runner 准备时间的门禁反复支付 setup/install 开销。 +- **将构建产物上传给依赖产物的 job**:在多 job 间保持正确性,但增加了 artifact upload/download 时间,且在产物消费方可以通过主 job 内的本地依赖运行时仍保持工作流过宽。 +- **并发运行 `typecheck` 和 `build`**:向调度器暴露更多工作,但两者都调用 `tsc -b`;在它们之间共享增量构建状态是一场不必要的竞争,换来的挂钟收益很小。 +- **使用无界的真实 API e2e 并行度**:否决。该套件包含大量真实模型/工具场景;worker 池需要一个显式的 `DSH_E2E_MAX_WORKERS` 上限,这样 CI 和本地运行都能扇出而不会把配额或资源问题隐藏在不稳定的限流失败背后。 + +## 后果 + +PR 反馈以少量 GitHub check 呈现,每个宽粒度 job 内部包含结构化的逐门禁日志块。这使 runner setup 开销可控、Actions UI 紧凑,代价是失去了每个叶子门禁各自独立的 status check。 + +宽粒度 lane 拆分比单一主 job 更频繁地重复 checkout、setup 和 install。这一 setup 开销是有意为之:在 GitHub 托管 runner 上,将 lint、覆盖率和快照回放放在同一个进程池中运行会严重超额占用 CPU,以至于单 job 的关键路径反而长于重复 setup 的方案。 + +这种拆分引入了一项维护义务:当 `package.json` 增删属于 CI 的门禁时,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 需要相应增删叶子。这一义务是有意的,因为该 runner 是同一套门禁词汇的并行执行计划,而非独立的质量策略。 + +兼容性信号窄于主 Node 24 信号。它证明源码图在每个宣称支持的运行时上能通过类型检查、且真实的未构建 workflow-worker 启动路径能正常执行,而不必重复文档、覆盖率、发布卫生、快照回放和其他不因 Node 版本而异的冒烟检查。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml new file mode 100644 index 0000000000..3adf939f24 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-parallel-pre-push-gates.md: 8a8813f2f3f6726ab2ab028757406d366ceb8b6d +2026-07-06-parallel-pre-push-gates.zh.md: 36207e522982990c193f9fe909faf380de53bca6 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md index f3a7b1e83c..8a8813f2f3 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -1,5 +1,7 @@ # RFC: Parallel pre-push gates +English | [中文](2026-07-06-parallel-pre-push-gates.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md new file mode 100644 index 0000000000..36207e5229 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -0,0 +1,42 @@ +# RFC:并行 pre-push 门禁 + +Status: implemented + +[English](2026-07-06-parallel-pre-push-gates.md) | 中文 + +## 问题 + +pre-push 钩子是分支离开本地机器前的最后一道检查点,因此它的挂钟时间直接影响贡献者是否愿意保持启用并信任其信号。Lefthook 已经能并行运行顶层 job,但 `pnpm run hygiene` 和 `pnpm run doc-sync` 这类聚合 job 在单个 job 内部隐藏了长串的顺序执行链。因此钩子可以配置为并行,却仍在等待那些成员彼此独立的串行子命令。 + +把这些成员直接展平到 `lefthook.yml` 只能解决本地钩子的问题。CI 有同样的调度问题,而在 YAML 中重复一份长长的叶子列表会让未来的脚本改动有两处可能漂移。 + +`publint` 在更低一层也有同样的形态。每个包独立地针对自身的 manifest 和构建产物做 lint,但运行器按顺序逐个遍历所有包。在本仓库中,这意味着一个包发布门禁消耗的时间与包数量成正比,尽管各检查之间并不共享可变状态。 + +## 决策 + +[lefthook.yml](../../../../lefthook.yml) 保留一个名为 `full check` 的 pre-push job,运行 `pnpm run check:pre-push`。该包脚本委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),即 CI 使用的同一个有界调度器。 + +`pre-push` 模式展开为以下叶子门禁:单元测试套件、快照测试套件、构建、`hygiene` 成员、`doc-sync` 成员,以及 module-graph 新鲜度。叶子列表保持与包脚本相同的门禁词汇(包括 RFC 分类和 RFC 格式),运行器并发调度独立检查,并为每个门禁打印一个计时/输出块。 + +构建门禁使钩子在干净 worktree 上也能自给自足。`publint` 和 `verify-node-next-types` 等待构建产物,而仅依赖源码的门禁继续并行执行。 + +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包列表,并使用大小取自 `availableParallelism()` 的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以为资源配置不同的本地机器和 CI runner 设置 worker 数量上限或提高上限。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱每个包的日志块。 + +聚合包脚本仍然是临时本地运行的真源。调度器是对其成员门禁的并行执行计划,而非替代词汇。 + +## 曾考虑的替代方案 + +- **在钩子中保留聚合的 `hygiene` 和 `doc-sync` job**:配置更简单,但 pre-push 的大部分挂钟时间仍然花在 lefthook 看不到也无法调度的串行命令链内部。 +- **为每个叶子门禁声明一个 lefthook job**:通过 lefthook 原生的 job 模型暴露并行性,但会让钩子文件承载一份 CI 无法复用的长成员列表。 +- **要求开发者在推送前手动构建**:省去一个钩子门禁,但会导致 `publint` 在干净 worktree 上失败,并把最后的本地检查点从可运行的检查降格为一项约定。 +- **在 shell 脚本中使用后台子命令**:能并行化工作,但会丢失 lefthook 的 job 名称、逐 job 计时和失败分组,且信号处理更难推理。 +- **为每个包声明一个 publint lefthook job**:暴露最大并行度,但会把钩子变成一份手工维护的包清单,恰好在新增包时漂移。 +- **以无界并发运行 publint**:仅在小型机器上以赌进程数、内存压力、包 tarball 创建和日志可读性为代价来最小化耗时。 + +## 后果 + +钩子的关键路径变为最慢的那个实际门禁,而非隐藏门禁链的总和。Lefthook 报告一个 `full check` job,运行器在该 job 内部报告逐门禁计时,因此本地检查点慢时仍能指出主导耗时的那个门禁。 + +钩子文件保持简短,重复的成员列表集中在 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中,CI 和 pre-push 可以共享。代价是一个自定义调度器脚本(而非纯 lefthook 配置),外加本地 pre-push 路径中的一次构建。 + +`publint-all.ts` 变为异步代码,缓冲命令输出而非实时继承 stdio。收益是包级并行、稳定的输出顺序,以及一个用于资源调优的环境变量。 diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml new file mode 100644 index 0000000000..bd24f4a6c1 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-10-readme-known-limitations-gate.md: b7f45421bf0d4d50ec1a19941934e782f52e7926 +2026-07-10-readme-known-limitations-gate.zh.md: 4dd8db1df9b0b2be73e7ae6a64e11b8dabc2add1 diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md index 6cd8851149..b7f45421bf 100644 --- a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md @@ -1,5 +1,7 @@ # RFC: A gated Known-Limitations section in every package README +English | [中文](2026-07-10-readme-known-limitations-gate.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md new file mode 100644 index 0000000000..4dd8db1df9 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md @@ -0,0 +1,29 @@ +# RFC:在每个 package README 中设置受门禁保护的「已知限制」章节 + +Status: implemented + +[English](2026-07-10-readme-known-limitations-gate.md) | 中文 + +## 问题 + +[文档标准](../../../AGENTS.md)将限制事项归属于 package README。如果没有统一的格式,缺失的章节无法区分「经过审计确认无限制」和「忘记写了」,而各式各样的标题也让仓库级搜索无从下手。 + +## 决策 + +`packages/<group>/<pkg>/package.json` 下的每个 package manifest(元数据清单)都有一个同级 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。该章节的条目记录该 package 拥有的持久性消费方缺口与非显而易见的维护约束;普通的清理工作留在源码 TODO 或所属 RFC 中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从 manifest 推导 package 集合,拒绝缺少 README 的情况,并要求恰好有一个规范的 h2 标题且至少包含一个顶级条目。近似标题(如 "Limitations"、"Deferred"、"What is NOT here" 或 "Non-goals")会导致失败。 + +如果一个 package 确实没有需要声明的限制,则将其列入 `NO_LIMITATIONS` 并省略该章节。新增限制时必须移除该条目;重命名或删除条目会失败,因为每个条目必须对应一个被扫描的 package。 + +门禁检查存在性、格式和白名单。覆盖率与准确性由文档标准和[行文标准](../../../../.agents/skills/dsh-prose-standard/SKILL.md)下的评审负责。常设规则见 [packages/AGENTS.md](../../../../packages/AGENTS.md)。 + +## 曾考虑的替代方案 + +- **自由格式标题**:无法统一搜索,仍然需要近似标题检测。 +- **要求空章节或写 "None."**:样板文字可能在 package 新增限制后仍然残留;白名单使「确认无限制」显式且可评审。 +- **施加字数上限**:合理的限制条目数量因 package 而异,因此由评审管控这一不设预算的 README 层级。 + +## 后果 + +- 新 package 要么声明符合条件的限制事项,要么显式加入白名单;缺失、漂移或空白的章节会在本地和 CI 的 `doc-sync` 中失败。 +- 门禁向 `doc-sync` 新增一个无外部依赖的 TypeScript 脚本。 +- 重命名被强制的标题需要同时修改脚本和所有 package README。 diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml new file mode 100644 index 0000000000..7796a46dfc --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-package-model-experience-contract.md: 036efbc9510d6d0ae9e3c52a5ba8f39647adc4c9 +2026-07-12-package-model-experience-contract.zh.md: 6ee96f3befbb2ead7196f412dccf915f475cfc6d diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md index 0f9b0b02a0..036efbc951 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md @@ -1,5 +1,7 @@ # RFC: Package Model Experience contract +English | [中文](2026-07-12-package-model-experience-contract.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md new file mode 100644 index 0000000000..6ee96f3bef --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md @@ -0,0 +1,33 @@ +# RFC:包(package)模型体验契约 + +Status: implemented + +[English](2026-07-12-package-model-experience-contract.md) | 中文 + +## 问题 + +一个包的 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的核心问题:这个包中有什么内容会进入模型请求、在什么条件下进入、以及这些 token 会保留多久。在插件架构中,这一缺失尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能把成功替换为错误,压缩(compaction)可能移除旧历史,agent 作用域的注册可能改变某个 agent 的提示词或 schema 而对其他 agent 毫无影响。因此只阅读名义上面向模型的包会遗漏真实的上下文影响,而逐依赖阅读源码对于日常评审又过于昂贵。 + +## 决策 + +每个具有面向模型或模型相邻契约的 workspace 包 README,都以规范的 [Model Experience 章节](../../../cookbook/adding-a-package.md#4-write-the-package-readme)结尾,紧接在 `## Known Limitations and Deferred Work` 之前;如果包在 no-limitations 允许列表上,则以 Model Experience 本身结尾。经审计确认为模型无关的通用包通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 + +具有直接、条件性、有上限、生命周期性、多表面或辅助模型效应的包,每个上下文表面使用一个 H3。每个 H3 说明相关模型接收到什么内容、何时接收,并对 token 效应进行分类。包所拥有的稳定文本逐字引用:系统提示词及其他长文本使用嵌套 H4 加 `markdown` 围栏,短文本则以行内形式保留,带命名的插值占位符。工具 schema 表面链接到生成的[工具目录](../../../tool-catalog.md)中对应的锚点章节,只陈述组合或配置差异;仅在运行时定义的则说明目录为何未收录。数据依赖和提供方拥有的文本以摘要形式呈现。agent 作用域的可见性须显式标注;当作用域可以隐藏提示词而不隐藏 schema(或反之)时,提示词和 schema 表面保持分开记录。 + +没有模型上下文效应的包,或其路径完全由另一个包渲染的包,使用验证器审计过的单句形式:`None, as ` 或 `Indirectly, through `。纯传输和无 ctx key 的测试支持包在不产生模型绑定内容时使用 none 形式。提供方后端即使会截断或过滤数据,也使用 indirect 形式;组装 bundle 在所有效应由具名子包拥有时同样使用 indirect 形式。这些句子定位贡献所在,而不重述消费方的内容。结构化章节同样只记录包自身拥有的输入、转换和差异。 + +`verify-package-readme-model-experience` 发现包的 manifest 并验证三种分类、规范的末尾章节顺序、必填字段、具体的文本证据、嵌套的逐字块以及锚定的工具目录链接。它在 `doc-sync` 和并行门禁运行器中运行。覆盖面、链接相关性和事实准确性仍由评审把关。 + +## 曾考虑的替代方案 + +- **只记录注册了提示词或工具的包**:否决。后端、策略插件、适配器、持久化、作用域和压缩都会改变 token 的内容或生命周期,却不拥有面向模型的 schema。 +- **从源码生成一份中央上下文成本目录**:否决。AST 能找到注册点,但无法推断语义条件,例如历史保留、输出截断、父子可见性或辅助模型边界。包 README 是实现本地的契约;中央副本会增加又一个漂移面。 +- **要求给出数值 token 计数**:否决。精确计数取决于所选模型的 tokenizer、适配器序列化方式、配置和运行时数据。稳定的契约是增长形态:每请求固定、每调用条件性、保留、替换、有上限或零直接。 +- **使用三列表格**:否决。精确的源文本和条件性结果形态使单元格过于密集、难以扫读。重复的子章节为每个上下文表面提供可读的纵向空间,同时保留相同的字段。 +- **允许所有零影响包省略该章节**:否决。无约束的缺失在「经审计的零影响」和「忘了写文档」之间是歧义的。省略仅限于在验证器中以理由具名的模型无关通用包;模型相邻的零影响包保留一句显式说明。 +- **要求经审计的零影响或简单间接包也使用完整结构化形式**:否决。围绕一个事实重复标签没有意义。一句受门禁约束的句子在保持显式覆盖的同时免去了仪式感。 +- **只有约定、没有门禁**:否决。仓库级契约必须覆盖未来的每个包;评审者的记忆无法可靠地检测到遗漏的 README 章节。 + +## 后果 + +评审者可以从任何面向模型或模型相邻的包出发,看到它对会话模型、子模型和辅助调用的贡献,而无需重建完整的插件图。token 预算工作可以区分每次请求的重复开销与数据依赖的历史,agent 作用域的变更有了显式的文档检查点。包作者在模型可见行为变化时维护一个或多个紧凑的上下文表面块,或一句经分类的句子;经审计的通用包不带无关的模型样板文字。结构化字段不承诺提供方精确的 token 计数;测量仍然是模型和负载特定的,而文档化的增长形态与可见性契约保持稳定。 diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml new file mode 100644 index 0000000000..f88f300193 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-19-drop-mutable-session-summary.md: 0d790191906a9128ad12d40526fde3b9f8fa939f +2026-06-19-drop-mutable-session-summary.zh.md: 97293c296cd74b5a33ce7e1ebe473c970ee95d57 diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md index 54f8397fc9..0d79019190 100644 --- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md @@ -1,5 +1,7 @@ # RFC: Drop the mutable session summary +English | [中文](2026-06-19-drop-mutable-session-summary.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md new file mode 100644 index 0000000000..97293c296c --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -0,0 +1,35 @@ +# RFC:移除可变的会话摘要 + +Status: implemented + +[English](2026-06-19-drop-mutable-session-summary.md) | 中文 + +## 问题 + +[会话持久化 seam](../architecture/2026-06-14-session-persistence.md) 将会话的日志外元数据拆分为 `dsh-session` 拥有的两种类型:一个不可变的 `SessionHeader`(`version`、`id`、`createdAt`、`cwd?`、`parentSession?`),在创建时一次性写入;一个可变的 `SessionSummary`(`updatedAt`、`title?`、`firstPrompt?`),「无需触碰仅追加日志即可更新」。二者的联合类型为 `SessionMeta = SessionHeader & SessionSummary`,抽象的 `SessionPersistence` 服务为此多出第七个方法 `update(id, summary)`,用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁写一个独立的原子 `.summary.json` **伴随文件**(临时写入 + rename,尽力而为);SQLite 在追加事务内更新 `updated_at`/`title`/`first_prompt` **列**。 + +摘要的设计初衷是服务于未来的会话选择器(通过 `updatedAt` 排序、用 `title`/`firstPrompt` 预览)。该选择器从未实现。对整个仓库的审计表明,`SessionSummary` 的全部表面积都是**死状态**: + +- `SessionPersistence.update()` 的**生产调用方为零**(所有 `.update(` 命中都是 `createHash().update()` 或测试代码)。 +- `firstPrompt` 在生产代码中**从未被读取**。 +- `title` 确实在 ACP bridge 中被读取,但来源是工具调用的 **presenter**(`present.title`),而非存储的会话元数据。 +- `updatedAt` **没有消费方**:`list()` 唯一的生产调用方读取的是 `meta.cwd`(`SessionHeader` 字段),用于在 `session/load` 时校验工作区;resume 读取的是 `createdAt`/`cwd`/`parentSession`,全部是 header 字段。 +- 决定性的事实:活跃的 `Session.header` 早已被类型化为 `SessionHeader` 而非 `SessionMeta`——摘要从未存在于活跃会话对象上;它只存在于持久化层,除了自身的契约测试之外无人写入、无人读取。 + +## 决策 + +彻底删除可变的会话摘要。`SessionSummary` 与 `SessionMeta` 这个名称一并移除;后端存储和返回的元数据仅为 `SessionHeader`。`SessionPersistence.update()` 从抽象服务和所有后端中移除。JSONL 去掉整套伴随文件机制(`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` 以及 load/list 的覆盖逻辑);SQLite 删除 `updated_at`/`title`/`first_prompt` 列及每次追加时的 `updated_at` 更新,其 `SCHEMA_VERSION` 从 `1 → 2`。 + +摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;最近活跃时间 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变的 header 中(`createdAt`、`cwd`)。唯一*不可*派生的——用户*手动编辑*的标题——没有任何实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 + +将此记录为决策,是因为它**持久**(收窄了一个公开服务契约和两个后端的磁盘格式)、**有争议**(摘要是有意的前瞻性设计,不是意外产物)、**出人意料**(未来读者看到 `SessionHeader` 而原始 RFC 描述的是 `SessionMeta`,否则会疑惑摘要为何消失)。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md)扫清了障碍:没有可变摘要,协调器的钩子接口就不需要 `updateSummary` 钩子,JSONL 伴随文件与 SQLite 列之间的持久性差异也随之消失,两个后端的写入路径得以收敛。 + +## 无需迁移 + +这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「预发布立场:地基优先于爆炸半径」一节),因此不存在需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论更旧还是更新,因此陈旧的 v1 数据库会被干净地拒绝,而非在新列集上半读半错。新建数据库写入当前版本号;这是唯一需要工作的路径。 + +## 后果 + +未来的会话选择器现在必须从日志派生预览和排序信息(或重新引入一个类型化字段),而不能直接读取现成的摘要行。这是正确的代价:为一个不存在的功能维护缓存,是每个后端都要承担的死重,也是每个契约测试都要断言的负担。这一原则——**通过的测试固定的是当前行为,不一定是正确行为;行为可能是过去妥协的产物**——现已作为独立约定记录在[根 AGENTS.md](../../../../AGENTS.md) 中,本次变更即为其实例。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml new file mode 100644 index 0000000000..0e73dbfe7e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-collapse-trace-only-session-events.md: 9156c2ab356b1c46758d9d2047d491cd1952cbc4 +2026-06-20-collapse-trace-only-session-events.zh.md: f2ecfbf46d8d70cf478d57eae2ed3a18ea8746fa diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index a93e3d3196..9156c2ab35 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -1,5 +1,7 @@ # RFC: Fold trace-only session facts into load-bearing events +English | [中文](2026-06-20-collapse-trace-only-session-events.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md new file mode 100644 index 0000000000..f2ecfbf46d --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -0,0 +1,44 @@ +# RFC:将仅用于追踪的会话事实折叠进承载性事件 + +Status: implemented + +[English](2026-06-20-collapse-trace-only-session-events.md) | 中文 + +## 问题 + +会话事件词汇中包含一些一等事件,它们既不属于可回放的对话历史,在生产环境中也几乎没有消费方。`usage` 在模型流式分片中已经存在,但循环又额外追加了一个独立的 `usage` 事件。`error` 与 `turn/end { kind: 'error', message, code }` 中的循环失败原因重复;ACP(Agent Client Protocol)结算读取的是 turn-end 原因,ACP 渲染忽略 `error` 事件,`deriveMessages()` 也跳过它。 + +这些事件让规范的 transcript(文本记录)看起来比实际更像遥测数据。它们增加了事件变体、不变式、测试、快照和持久化用例,但作为独立记录并不承载实际负荷。它们携带的事实仍然有用:token 用量应当保留以供核算,错误的步骤编号也不应悄然消失。简化的方式是将这些事实折叠进消费方本就必须理解的邻近事件,而非减少记录的信息量。 + +## 决策 + +仅在信息已被保留、无需并行记录的位置移除独立的追踪事件: + +- 成功步骤的 usage 折叠进对应的 `assistant/message`(`assistant/message { turn, step, content, usage? }`),使组装好的模型输出与其核算信息一同传递。 +- 失败或中止的步骤如果有 usage 但没有 assistant 内容,则将 usage 挂在一个空内容的 `assistant/message` 上(下方实现说明给出了无信息丢失的证明)——不会有任何已持久化的 usage 分片失去表示。 +- 独立 `error` 事件中的步骤编号折叠进 `turn/end.reason`(当 `kind: 'error'` 时:`{ kind: 'error', step, message, code? }`)——`turn/end` 是 ACP 和恢复机制已在消费的持久化轮次结果。 +- `agent/error` 和日志保留用于实时诊断;`turn/end` 之后不再有第二条会话日志错误记录。 + +用户对话日志包含渲染、恢复、审计和核算交互所需的全部信息,消费方无需对账重复的追踪行。 + +## 曾考虑的替代方案 + +**保留独立行作为遥测**:这些事件让规范的 transcript 看起来比实际更像遥测数据,代价是增加了事件变体、不变式、测试、快照和持久化用例,却没有消费方使用。如果分析需求真正出现,正确的形态是投影辅助工具或带有独立保留策略的专用遥测存储,而非在对话日志中放置重复的追踪行。 + +## 验证 + +`SessionEventMap` 不再包含独立的 `usage` 或 `error`;循环不再追加独立的 usage 事件,持久化的失败通过 `turn/end { kind: 'error', step, message, code? }` 记录;ACP 快照和持久化测试断言不存在仅追踪行;录制的 fixture(测试前置数据)已采用新事件形状,会话格式版本固定为 `0`(按预发布格式策略,后端拒绝任何非 `0` 的存储日志);文档说明了 token 用量和操作错误的观测位置。 + +## 后果 + +消费方不能再从规范日志中筛选独立的 `usage` 或步骤级 `error` 行,必须从承载它们的 assistant/failure 事件中读取这些事实。只有当实现 PR 证明相同的事实仍然存在时,这才是合理的简化;否则独立事件应当保留。 + +## 实现说明 + +按提案交付,有一处范围细化(遵循 AGENTS.md「RFC 是提案,不是金科玉律」): + +- **空内容的 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片失去表示)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),此前会发出独立的 `usage`。现在它记录一条空内容的 `assistant/message { content: [], usage }`。为避免这向提供方 transcript 注入一个无内容的虚假 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。一个回归测试断言 usage 仍有表示,且派生历史未被破坏。 + +**格式版本。** 此变更改动了持久化事件,但预发布会话格式仍固定为 `0`,拒绝任何其他版本且不做迁移。`dsh-session` 拥有写入方和加载校验使用的常量。单调递增的格式版本从首次正式发布开始。 + +Usage 现在通过 `assistant/message.usage` 观测;操作错误的步骤编号通过 `turn/end.reason`(当 `kind: 'error'` 时)观测。`agent/error` 加日志用于实时诊断,保持不变。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml new file mode 100644 index 0000000000..8586b4051e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-drop-unconsumed-llm-adapter-change-event.md: efe90c0197671ef4385ce517540b4b238962c3b5 +2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: 13f01566472a030cc9d4f97f6e4438fe4c3ecbf8 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index ab921dd62c..efe90c0197 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -1,5 +1,7 @@ # RFC: Drop the unconsumed `llm/adapter-change` event +English | [中文](2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md new file mode 100644 index 0000000000..13f0156647 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md @@ -0,0 +1,36 @@ +# RFC:移除无消费方的 `llm/adapter-change` 事件 + +Status: implemented + +[English](2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 中文 + +## 问题 + +`LlmService.registerAdapter()` 在注册和 dispose(资源释放)时发射 `llm/adapter-change`([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))。在 `packages/*/src` 和 `examples/*/src` 中 grep `llm/adapter-change`,只能找到声明、发射点、文档和测试;没有任何生产代码监听它。 + +这与 `tools/change` 和 `system-prompt/change` 不同。后两个事件目前同样无消费方,但它们是合理的注册表变更信号,未来的实时工具/提示词 UI 可能用到。LLM 适配器注册更像是启动时的实现细节:适配器不是用户可见的面板,真正的模型调用拦截 seam 是 `llm/stream`。保留一个没有监听者的 adapter-change 事件,是 [drop-the-dead-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 模式在更小尺度上的重复。 + +这个事件并非零成本。`registerAdapter()` 在发射 `llm/adapter-change` 之前先 yield 回滚 disposer,这样抛异常的监听者会回退变更而不是泄漏一条适配器条目;包里还有测试覆盖这条监听者抛异常的路径。这种防御性排序所保护的失败模式,只有测试才能触发。 + +## 决策 + +只移除 `llm/adapter-change`:`dsh-llm` 的 `interface Events` 中的声明、`ctx.emit('llm/adapter-change')` 调用,以及 `LlmService.registerAdapter` JSDoc 中「在注册和 dispose 时发射 `llm/adapter-change`」的描述。`registerAdapter()` 的 effect generator 保留变更与回滚 disposer(用于 HMR(热模块替换)/dispose),但去掉仅为已移除事件而存在的监听者抛异常回滚排序。适配器 disposer 测试断言返回的 disposer 能移除适配器,不再订阅该事件;监听者抛异常的回滚测试随其主题一同移除。[docs/architecture.md](../../../architecture.md) 和 [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) 中的事件分类体系在同一个变更中更新。 + +## 曾考虑的替代方案 + +### 为什么不移除所有注册表变更事件? + +一个注册表主动广播变更的微内核是一种自洽的约定。`tools/change` 和 `system-prompt/change` 在 UI 能实时刷新可用工具或提示词段落时可能变得有用。本 RFC 在有合理的面向用户消费方的地方保留该约定,仅裁掉当前和可预见未来都没有明确消费方的 adapter-change 事件。 + +如果将来需要 LLM 适配器浏览器或动态模型选择器,届时再连同消费方一起重新引入该事件,并给出比「something changed」更清晰的 payload。 + +## 验证 + +`llm/adapter-change` 及其发射点已移除,重新生成的 cordis catalog 是最新的;HMR 安全性保持(dispose 一个贡献 fiber 会移除对应适配器);`tools/change` 和 `system-prompt/change` 仍有文档和测试;没有任何生产路径的可观测行为发生变化——ACP 快照 golden 和 echo-agent 冒烟测试逐字节不变。 + +## 后果 + +- **移除一个已文档化的发射事件属于公开接口变更。** 它出现在分类体系表中,读起来像是有意为之的 API。但「已声明并发射」不等于「有消费方」——这正是当初移除可变 summary 时所依据的同一区分。分类体系表在同一个变更中更新,因此文档不会漂移。 +- **注册表变更约定变得不均匀。** 这是可以接受的,因为 LLM 适配器注册与工具或提示词段落不是同一层面的用户可见概念。不均匀但诚实,胜过统一但空转。 + +这是一个小裁剪,但它退役了一条守护着不存在的消费方的常设正确性不变式。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml new file mode 100644 index 0000000000..46b752f26a --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: c8999dd0e19b2c8eaff854c8ff544bae2fc068b6 +2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 090d3e779e3e7a9f1f0a65af7740ad685f723cf6 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index 2b10bf36db..c8999dd0e1 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -1,5 +1,7 @@ # RFC: Drop unconsumed assembled LLM convenience surfaces +English | [中文](2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md new file mode 100644 index 0000000000..090d3e779e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md @@ -0,0 +1,39 @@ +# RFC:移除未被消费的 LLM 组装便利接口 + +Status: implemented + +[English](2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 中文 + +## 问题 + +`LlmService`([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))在模型之上暴露了三个调用接口: + +- `stream()`:原始 `StreamChunk`,通过 `llm/stream` waterfall(瀑布式事件)分发。 +- `streamBlocks()`:一个"便利视图",将 chunk 送入 `BlockAssembler` 并按流顺序 yield 已组装完成的 `ContentBlock`([index.ts:137-144](../../../../packages/llm/llm/src/index.ts))。 +- `generate()`:一个完整组装的 `GenerateResult`,通过第二个 `llm/generate` waterfall 分发([index.ts:151-157](../../../../packages/llm/llm/src/index.ts))。 + +LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体循环),它只使用 `stream()`:将原始 chunk 送入自己的 `BlockAssembler`,以便在并行组装的同时记录 chunk 用于回放保真([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts) 中的 `ctx.llm.stream(req)` 步骤)。在 `packages/*/src` 和 `examples/*/src` 中搜索 `streamBlocks` 与 `ctx.llm.generate`,找不到任何生产调用方。引用它们的只有服务方法定义、文档和测试;适配器测试用 `generate()` 作为便利驱动,但它们完全可以通过同一个 assembler 辅助函数手动消费 `stream()`,无需保留一个公开的生产 API。 + +这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:拥有测试契约的组装视图 API,消费方只有测试而非生产代码。它们是为"不关心 token 级增量"的消费方预先构建的,但唯一的真实消费方恰恰需要增量,以便持久化高保真的回放数据。 + +`streamBlocks()` 拖带了 `BlockAssembler` 中一块专用逻辑:`flushReady()` 和 `flushRemaining()`([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts))以及 `flushed` 游标字段,仅为支持按序增量 yield 而存在。`generate()` 拖带了 `GenerateResult`、`BlockAssembler.result()` 以及 `llm/generate` waterfall——在同一底层流之上多出的第二个拦截面。agent loop 对 assembler 的使用仅限 `push()` / `message()` / `usage` / `finish`,不涉及流式 flush 或一次性服务组装。 + +## 决策 + +`stream()` 是唯一的公开 LLM 调用接口。移除 `streamBlocks`、`generate`、其事件/结果类型,以及仅被该路径使用的 assembler 辅助方法。适配器测试通过本地辅助函数对公开的 stream 进行组装,`BlockAssembler` 只保留有生产消费方的操作。 + +## 曾考虑的替代方案 + +**保留 `generate()` 作为仅供测试的便利方法**:否决。适配器测试通过共享 assembler 手动消费 `stream()`,走的是与生产相同的流式路径;一个唯一调用方是测试的公开方法,正是[移除可变摘要先例](2026-06-19-drop-mutable-session-summary.md)所清退的死接口形态。未来如果有消费方需要不带增量的组装块,届时再引入一个有真实消费方的专用辅助方法。 + +## 验证 + +`streamBlocks`、`generate`、`llm/generate` 以及仅被它们使用的 assembler 辅助方法已全部移除,无新增死导出;两个真实适配器通过 `stream()` 加共享 assembler 得到充分测试;agent loop 行为不变(ACP 快照 golden 文件无变化);README、架构文档与模块文档中不再提及被移除的接口。 + +## 后果 + +- **从一个核心词汇包中移除了公开方法。** 未来如果有插件需要不带增量的组装块,它需要直接调用 `stream()` 并使用 `BlockAssembler`,或在有真实消费方时重新引入一个专用辅助方法。鉴于预发布阶段「基础优先于投机性未来」的立场([AGENTS.md](../../../../AGENTS.md)),现在正是清除仅供测试的公开形状的正确时机。 +- **适配器测试变得更显式。** 它们失去了便利的 `generate()` 包装层,但这是有益的压力:测试走的是与生产相同的流式路径。 +- **waterfall 使用方失去 `llm/generate`。** 不存在生产监听者。未来的缓存/重试/日志插件应包装 `llm/stream`,它仍是唯一的提供方调用路径。 + +变更规模不大,但它干净地从 LLM 包中移除了投机性的接口面积,为生产和测试留下唯一一份模型调用契约。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml new file mode 100644 index 0000000000..20c7e3718a --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-prune-dead-seam-methods.md: cb3eb576dbae209ddccbea7f42a80ef09f842887 +2026-06-20-prune-dead-seam-methods.zh.md: d3c658ea2ce994e640ec4fd2cf5f74d82f695e69 diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index ce16ff2ee2..cb3eb576db 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -1,5 +1,7 @@ # RFC: Prune dead methods from the persistence seam +English | [中文](2026-06-20-prune-dead-seam-methods.zh.md) + Status: implemented > **Implementation note:** Only `SessionPersistence.has()` and `.delete()` were removed. `BashExecutor.get()` and `.list()` remain because removing their one-line lookup surface required substantially more completion-tracking machinery in consumers. Their id branding is covered by the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md). diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md new file mode 100644 index 0000000000..d3c658ea2c --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md @@ -0,0 +1,43 @@ +# RFC:清理持久化 seam 中的无用方法 + +[English](2026-06-20-prune-dead-seam-methods.md) | 中文 + +Status: implemented + +> **实现说明:** 最终只移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 保留,因为移除它们的单行查找接口需要在消费方引入大量额外的完成状态跟踪机制。它们的 id 品牌化由 [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) 覆盖。 + +## 问题 + +一个能力 seam([接口/实现/消费方](../../implemented/architecture/2026-06-13-capability-seams.md))携带了没有任何消费方调用的抽象方法。seam 存在的意义是让实现与消费方独立演进,但一个没有消费方编程依赖的方法不是 seam,而是投机性的接口面——每个实现仍然必须实现并测试它。 + +### `SessionPersistence.has()` 与 `.delete()` + +抽象服务在 create/append 之外声明了更多操作:`load`、`list`、`has`、`delete`。`ctx.sessionPersistence` 的生产消费方只用到两个:agent loop(智能体循环)的恢复路径调用 `load()`([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)),ACP 桥接层为 `session/list` 调用 `list()`([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts))。在 `packages/*/src` 和 `examples/` 中 grep 所有 `sessionPersistence.*` / `persistence.*` 用法,找不到对该服务的 `has(` 或 `delete(` 调用。`packages/ui/acp/src/index.ts` 中的 `.has(`/`.delete(` 调用作用于内存中的 `SessionStore` 和一个本地的 loading id `Set`,而非持久化服务。`has`/`delete` 的唯一调用方是契约测试套件和各后端的 spec。 + +`has()` 不仅仅是未使用——它还是共享协调器中最复杂的分支:一个 tracked-vs-untracked 双探测(`loadLive(id, cwd)` 用于活跃跟踪的会话,`loadStored(id)` 用于未跟踪的会话),附带多行注释说明理由。`delete()` 则拖带了 `deleteStored` 后端钩子,每个后端都必须实现它。这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:契约测试覆盖了两者,但没有任何发布代码会问「这个会话是否已持久化?」或删除一个会话。 + +## 决策 + +没有消费方使用的方法被移除——从抽象 seam、实现,以及仅为覆盖它们而存在的契约/spec 测试套件中移除: + +- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` 钩子(jsonl 和 sqlite 各自实现 `deleteStored` 仅仅是为了满足该钩子——那些实现也一并移除)。后端属于[双后端](../../implemented/architecture/2026-06-14-session-persistence.md)设计,本身不在本 RFC 范围内;移除它们为无消费方实现的钩子是移除钩子的一部分,而非后端重设计。 +- 所有文档和源码注释中的引用都已更新为存活的四方法、仅含 `list()` 的契约——不仅是字面的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和「六个公开方法」之类的计数——涉及 seam 和后端 README、[docs/architecture.md](../../../architecture.md)、[session-persistence](../../implemented/architecture/2026-06-14-session-persistence.md) 与 [write-coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) RFC,以及协调器/后端的 JSDoc。 + +## 曾考虑的替代方案 + +### 为什么不以「seam 应当完整」为由保留? + +「持久化 seam 理应提供 delete」这种直觉是真实的——而它恰恰是预发布阶段所警惕的投机完整性([AGENTS.md](../../../../AGENTS.md):为正确的基础优化,而非为你并不拥有的假想调用方优化)。`delete()` 只是一个方法,等到消费方真正需要时再加回来即可:一个删除旧会话的会话管理 UI 会需要它——到那时再加,针对该 UI 的真实需求设计(软删除?级联?确认?),而非现在猜测。 + +在有活跃消费方时重新加入一个 seam 方法,成本低且设计更优,因为消费方锁定了契约。无人使用地携带它,意味着每个实现(以及未来的每个后端)都必须实现并测试一个什么也不做的方法。 + +## 验证 + +`has`/`delete`/`deleteStored` 已从持久化 seam、实现和契约测试套件中移除,没有新增无用导出;剩余操作(`create`/`append`/`load`/`list`)未受影响,ACP `session/list` 和崩溃恢复行为完全一致;seam README 和 `docs/architecture.md` 只列出存活的方法。 + +## 后果 + +- **`delete()` 是产品最终会需要的那类操作。** 确实如此——但「最终」正是关键。现在删除、等有真实消费方时再加回来,严格优于发布一份猜测的契约。双后端各自去掉了一个 `deleteStored` 实现,这是在本来不在范围内的包中的有限改动。 +- **低耦合。** 移除局限于持久化 seam + 实现 + 测试;没有跨包消费方引用被移除的方法,因此文档之外没有涟漪效应。 + +规模不大,但它将 seam 从「实现必须为无人提供什么」恢复为「恰好是消费方使用的东西」。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml new file mode 100644 index 0000000000..dd5dd8e4e1 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-public-agent-stop-surface.md: 8c371911616b0a156156355b2ca15d795cfd21e5 +2026-06-20-public-agent-stop-surface.zh.md: 31deaa3649026a7579702e8e47edfdf05d2543ae diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index 7ed7d10211..8c37191161 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -1,5 +1,7 @@ # RFC: Keep one public stop primitive +English | [中文](2026-06-20-public-agent-stop-surface.zh.md) + Status: implemented > **Implementation note:** Only `abort()` was removed. `whenIdle()` remains because it is the public quiescence signal and safely handles waiter settlement and replacement-turn races; consumers should not reconstruct that behavior from status transitions. diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md new file mode 100644 index 0000000000..31deaa3649 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -0,0 +1,39 @@ +# RFC:保留单一公开停止原语 + +[English](2026-06-20-public-agent-stop-surface.md) | 中文 + +Status: implemented + +> **实现说明:** 仅移除了 `abort()`。`whenIdle()` 予以保留,因为它是公开的静默信号,能安全处理等待者结算与替换轮次竞态;消费方不应从状态转换中自行重建该行为。 + +## 问题 + +公开的 `Agent` 句柄暴露了两种重叠的方式来停止进行中的工作:`abort(reason?)` 与 `cancel(reason?)`。`abort()` 仅终止当前正在执行的步骤,不影响队列中的工作;`cancel()` 清除队列中的工作与 steering(中途引导),终止正在运行的步骤,并处理步骤前竞态。在生产环境中,ACP(Agent Client Protocol)使用 `cancel()` 实现 `session/cancel`,而生命周期所有者通过 `AgentHandle.dispose()` 拆除 agent。没有生产调用方需要裸 `abort()`。 + +`abort()` 与 `cancel()` 的区别是真实存在的:`abort()` 保留队列中的提示词和 steering,而 `cancel()` 丢弃它们。但没有已发布的代码调用过公开的 `abort()` 动词。agent loop(智能体循环)自身的停止路径(`cancel()` 与 dispose)直接终止当前 `AbortController`,而非经由 `Agent.abort()` 路由。大多数调用 `abort()` 的测试实际上中断的是空队列,可以改用 `cancel(reason)`;那个刻意依赖队列保留的 steering 重投递测试则直接驱动进行中的 `AbortController`,因为 `cancel()` 会丢弃它试图证明在步骤终止后仍存活的队列 steering。无参 `abort()` 的默认原因(`'aborted'`)随动词一起删除,而非意外保留;`cancel()` 保留自己的默认值 `'cancelled'`。 + +多余的公开接口面使 agent loop 不得不承载一个本质上是拆除内部机制的公开动词:`abort()` 必须被文档描述为与队列感知的取消不同,尽管 UI 取消几乎总是需要更广义的操作。 + +## 决策 + +`cancel()` 是 `Agent` 上唯一的公开*停止*原语。生命周期所有者使用 `AgentHandle.dispose()` 停止并注销 agent;非所有者使用 `cancel()` 放弃当前与队列中的工作。实现内部保留一个私有 abort controller,但它不属于面向插件的 `Agent` 契约。 + +`whenIdle()` 作为公开的静默观测原语**予以保留**(agent 脱离 `running` 状态后 resolve;已处于 idle 时立即 resolve;dispose 后等待循环退出)。它不是停止动词;它是非所有者观测停止*完成*而无需 dispose agent 的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 拆除它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 + +公开的 `abort()` 被删除,连同将其作为独立 API 测试的用例以及将步骤级终止描述为嵌入特性的文档。空队列终止测试迁移到 `cancel(reason)`,仍然验证取消行为;测试对象为 agent loop 内部 `AbortController` 的测试通过包内类型转换直接驱动该 controller 的私有字段;仅固定已移除的无参 `abort()` 默认值的测试随方法一起删除。disposer 仍为异步,仍等待循环停止。 + +## 曾考虑的替代方案 + +**同时移除 `whenIdle()`**:最初提案的形态,在对照代码验证前提后被推翻(上方的实现说明记录了完整过程):它是承重的静默原语,强迫消费方手动观测 `running`→`idle` 转换正是防御性模式所警告的脆弱路径。 + +## 验证 + +`Agent` 不再暴露公开的 `abort()`,而 `cancel()`、`whenIdle()` 与 `steer()` 保留;ACP 取消调用 `cancel()`;拆除通过 handle disposal 等待静默,`whenIdle()` 为非所有者观测者在静默时 resolve;测试套件覆盖取消与 disposal 作为两条受支持的停止路径。 + +## 后果 + +未来的插件无法通过公开接口仅终止当前模型/工具步骤而保留队列中的提示词。如果该用例变为现实需求,它应当带着一个具名消费方和更窄的契约重新引入。目前它只是把一个私有循环机制暴露为公开接口的潜在泛化。 + +## 相关 + +本 RFC 仅移除冗余的停止动词。中途 steering 仍是有意保留的消息路径;静默观测仍通过 `whenIdle()` 提供。最终的公开接口面为 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 与 identity。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml new file mode 100644 index 0000000000..4f09cbbc3b --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-remove-agent-boundary-mirror-events.md: 46b5e43951885915d4c3dd3f867ced6c31d32035 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: dd6952ec8923c17d703fc6850197bef09b1c0ee7 diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index e2a1165846..46b5e43951 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -1,5 +1,7 @@ # RFC: Stop mirroring durable boundaries as agent events +English | [中文](2026-06-20-remove-agent-boundary-mirror-events.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md new file mode 100644 index 0000000000..dd6952ec89 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -0,0 +1,32 @@ +# RFC:停止将持久化边界镜像为 agent 事件 + +Status: implemented + +[English](2026-06-20-remove-agent-boundary-mirror-events.md) | 中文 + +## 问题 + +agent loop(智能体循环)曾通过可回放的 `SessionEvent` 日志和实时 `agent/*` 镜像两条路径暴露持久化的轮次与步骤边界。消费方不得不在两个表达同一事实的来源之间做选择,并协调二者的时序。ACP(Agent Client Protocol)和持久化层已经使用事件日志;stdio UI 是唯一仍在消费镜像事件的组件,而它也已经从 `session/event` 渲染工具调用和工具结果。 + +这种重复并非零成本。每次生命周期变更都要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法的位置可插入,只能带外报告。 + +## 决策 + +让 `session/event` 成为唯一的实时边界/transcript(文本记录)流。需要渲染轮次、工具调用、工具结果、助手消息和持久化边界的消费方统一订阅 `session/event`,从持久化层使用的同一套事件词汇派生 UI。 + +移除 `agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。边界消费方改为订阅 `session/event`。需要 agent 标签的 UI 通过 `agent/created` 和 `agent/disposed` 维护一份 session 到 agent 的映射,因为持久化的 `turn/start` 携带轮次编号但不携带 agent id。 + +步骤镜像没有消费方,已由 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 率先移除。该决策保留了轮次镜像供 stdio UI 使用;本 RFC 在将测试 REPL 迁移到 `session/event` 加 id 映射之后,将轮次镜像也一并移除。 + +## 范围:移除什么、不移除什么 + +本决策仅涉及持久化的轮次与步骤边界。`agent/steering` 镜像的是一条控制记录,`agent/stream-chunk` 镜像的是 token 流,因此各自单独处理:见 [steering](2026-07-04-remove-agent-steering-mirror.md) 和 [stream chunks](2026-07-02-remove-stream-chunk-mirror.md)。`agent/created`、`agent/disposed`、`agent/status`、`agent/error` 和 `agent/queued` 仍作为实时生命周期或控制事件保留,而非 transcript 镜像;排队的输入可能在任何持久化事件产生之前就被取消。 + +## 曾考虑的替代方案 + +- **在同一个变更中移除 `agent/steering`**:否决,因为它镜像的是控制记录而非边界。 +- **为 stdio UI 保留轮次镜像**:否决,因为 UI 可以渲染 `session/event` 并从 id 映射中恢复 agent 标签。 + +## 后果 + +插件不再能从便捷的 `Agent` 优先事件中观察轮次/步骤边界。它必须订阅 `session/event` 或自行维护 session 到 agent 的关联。这是可接受的取舍:边界消费方不应依赖一条可能与持久化日志产生漂移的第二事件源。 diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml new file mode 100644 index 0000000000..f096620016 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-26-fsspec-style-fs-seam.md: 493af9341177aaaed4cdca03a3c20f326c5c4dac +2026-06-26-fsspec-style-fs-seam.zh.md: ba6a366990f749c5fb84e30142965dc5a2d1d0b7 diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 2663637859..493af93411 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -1,5 +1,7 @@ # RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin +English | [中文](2026-06-26-fsspec-style-fs-seam.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md new file mode 100644 index 0000000000..ba6a366990 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md @@ -0,0 +1,130 @@ +# RFC:拆分文件系统 seam——提供方文本变更与 `dsh-fs-policy` 插件 + +Status: implemented + +[English](2026-06-26-fsspec-style-fs-seam.md) | 中文 + +## 问题 + +[filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 引入的文件系统能力目前让一个抽象 `FileSystem` 服务同时承担两类职责: + +1. **提供方操作**——解析目标、stat/版本元数据、文本读取/流式读取、原子写入,以及带守卫的字面编辑。 +2. **面向 agent 的策略**——行窗口、字面编辑语义,以及读后写/编辑的 observed-state。 + +这导致每个未来的后端都要重新实现面向模型的读取语义和观测策略。`readPage` 返回带行号的行和视图元数据;基类服务按 owner 存储文件状态,并区分 `full` 与 `partial` 读取。这些是有用的策略,但它们不是文件系统提供方的原语。字面文本变更则不同:版本守卫、字面匹配、歧义检测与原子重写必须在提供方变更边界内保持一体,但当前的 `applyEdit` 命名及其周围的 seam 把这个提供方操作绑定到了旧的读后编辑策略形状上。 + +这还造成了一个真实的 UX 死胡同:窗口化读取记录 `view: partial`,而 partial 视图无法授权 `edit`。一个模型读取了大文件的第 100-150 行,除非先获得一次 `full` 读取,否则无法编辑第 120 行——而对于超过读取上限的文件,full 读取可能不可行。字面编辑真正需要的只是新鲜度:被匹配的字节必须仍来自模型所读的那个版本。 + +旧 RFC 已经推迟了独立的 `@deepseek-ai/dsh-fs-policy` 包。本 RFC 构建该层,并让 `ctx.fs` 贴近 fsspec 风格的存储原语(`info`/`cat`/`open`),但不将其变成完整的 fsspec。 + +## 决策 + +将栈拆为四层: + +```text +tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events) +policy dsh-fs-policy observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) +provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard) +provider dsh-fs-local local implementation of ctx.fs +``` + +`dsh-tool-fs` 保持相同的面向模型的 `read`/`write`/`edit` schema。它是执行器:注入 `fs`(不是策略服务)并直接访问 `ctx.fs`,拥有读取窗口化逻辑,并派发 `fs/*` 事件以便 `dsh-fs-policy` 进行门控和记录。 + +本 RFC 决定了四层拆分、提供方契约和新鲜度策略。工具↔策略的**耦合方式**随后由[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 细化:`dsh-fs-policy` 是一个门控**插件**,通过 `fs/*` 事件参与而非提供 `ctx.fileContext` 方法服务,因此工具不与它产生方法耦合,读取窗口化与 fs I/O 留在 `dsh-tool-fs` 中。本文描述的是最终落地的事件门控形态;提供方的版本守卫是可选的(省略 = 无条件裸提供方)。 + +## 提供方契约 + +`@deepseek-ai/dsh-fs` 收缩为提供方文本 IO 加带守卫的文本变更: + +```ts ignore-check +abstract resolve(path: string): Promise<FsTarget> +abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> +abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string> +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> +abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome> +abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome> + +interface FsInfo { + version: FsVersion + type: 'file' | 'directory' | 'other' + size?: number +} + +type FsWriteIntent = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } +``` + +`stat` 返回元数据而非内容。`version` 是新鲜度令牌;`type` 让执行器在读取前拒绝目录/特殊文件;`size` 让 `read` 工具无需通过失败来探测即可选择 `readText` 还是 `streamText`。返回 `undefined` 表示目标不存在。 + +`readText` 读取整个常规文本文件。`streamText` 以相同的文本语义流式读取大文件。两个提供方原语负责常规文件检查、UTF-8 解码、二进制/NUL 拒绝以及 `FS_NOT_TEXT`;策略层从不处理原始字节,也不重新实现跨分片解码。`readText` 是小文件/直接全文件原语,而面向模型的大文件读取使用 `streamText`。 + +`writeText` 是原子性的临时文件 + rename,带有显式的写入意图。`createIfAbsent` 创建不存在的目标,对已存在的目标以 `FS_NOT_OBSERVED` 拒绝;这是 owner 没有先前读取时使用的路径。`replaceIfVersion` 仅在目标以观测到的版本存在时替换;目标不存在或版本不匹配时抛出 `FS_STALE_VERSION`。 + +`editText` 是提供方级别的带守卫文本变更。启用守卫时,它先验证目标仍以 `expected.version` 存在,然后读取当前文本、应用字面替换并原子写入。陈旧检查必须在字面匹配之前发生,这样基于旧读取的编辑会报告 `FS_STALE_VERSION`,而不是对更新内容做匹配后报告 `FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`。将此原语保留在提供方 seam 上,保持了后端本地锁定能力,也让未来的远程后端可以实现原生的 compare-and-edit 而无需策略层拉取整个文件。 + +这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半层。UTF-8 解码、二进制/NUL 拒绝、带守卫的全文件写入和带守卫的字面文本编辑都在提供方内完成,使策略层从不接触原始字节、不重新实现跨分片解码、也不将陈旧检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、observed-state 存储都不会泄漏下去。 + +从 `dsh-fs` 中删除:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody`,以及 observed-state `WeakMap`。`applyEdit` 被更窄的提供方原语 `editText` 取代,后者的契约是版本守卫的字面文本变更,而非策略层的读取授权。`FS_PARTIAL_OBSERVATION` 错误码也从 `FsErrorCode` 分类体系中移除:新鲜度授权没有 partial/full 之分,因此没有任何场景会抛出它。`FsTargetKey` 和 `FsVersion` 按照既有的 [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md) 成为品牌化的不透明 id。 + +## 策略契约 + +`@deepseek-ai/dsh-fs-policy` 是一个插件而非服务:它不注册任何 `ctx.*` 键,也不注入任何东西。它拥有写入/编辑新鲜度策略和 observed-state——这些不属于 `FileSystem` 提供方基类(否则沙箱化/远程后端会继承它无需承担的面向模型的观测策略)。它通过执行器派发的 `fs/*` 事件门控来贡献这些策略。(本 RFC 最初提出了一个具体的 `ctx.fileContext` 方法服务,带 `read`/`write`/`edit` 方法;[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 将其细化为此处描述的门控插件,使工具从不与策略产生方法耦合。) + +Observed state 以 `WeakMap<owner, Map<targetKey, FsVersion>>` 形式存在于此。当且仅当 owner 读取、写入或编辑过该目标时条目才存在(每次成功都会发出 `fs/observed`),因此条目的存在*本身就是*先前观测记录——没有单独的 `hasRead` 标志。owner 从不透明的事件 actor(`{ agent?: { session? } }`)结构化派生,该形状定义在 `dsh-fs-policy` 中而非 `dsh-fs` 中。 + +该插件决定三个 `fs/*` 事件: + +- `fs/write-intent`——无先前观测 ⇒ `{ kind: 'createIfAbsent' }`(只有新文件可以盲创建);有先前观测 ⇒ `{ kind: 'replaceIfVersion', version: vObserved }`(已有文件仅在自观测以来未变时才替换)。单槽决策;不调用 `next()`。 +- `fs/edit-intent`——要求 owner 有先前观测(否则 `FS_NOT_OBSERVED`);返回 `{ version: vObserved }` 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一个赢/一个陈旧。 +- `fs/observed`——在成功的读取/写入/编辑后为该 owner+target 记录 `{ version }`。同步、仅副作用的 `WeakMap.set`。 + +该插件不做任何文件系统 I/O:「你是否观测过这个文件?」是一次 `WeakMap` 查找,而「你读到的版本是否仍然是当前版本?」在 `ctx.fs.editText`/`writeText` 内部的同一原子锁中决定(该锁同时执行变更)——插件只提供 `vObserved` 作为基础。 + +## 工具契约 + +`dsh-tool-fs` 保持相同的 schema 和提示词表面。`read` 仍暴露 `file_path`、`offset` 和 `limit`;`write` 和 `edit` 不变。它是执行器:验证模型参数,通过 `ctx.fs` 直接读取/写入/编辑,拥有行窗口化和结果渲染(`N: text`、页脚、`<path>/<content>` 信封),并派发 `fs/*` 事件。 + +每次变更先派发其 intent waterfall(瀑布式事件)并以 `undefined` 作为裸提供方默认值,然后调用 `ctx.fs`,再发出 `fs/observed`:例如 `write` 执行 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`。`read` 做一次 stat、读取/流式读取、构建窗口,然后发出 `fs/observed`。将 `exec` 作为 actor 传入,让 `dsh-fs-policy` 无需工具深入策略即可派生 owner。 + +由于策略通过带 `undefined` 默认值的事件贡献,`dsh-tool-fs` 不与 `dsh-fs-policy` 产生方法耦合:插件不存在时,每个 intent waterfall 落入 `undefined`(无条件裸提供方写入/编辑),`fs/observed` 无监听者。加载插件后即叠加读后写/编辑策略。 + +## 并发边界 + +进程内更新是安全的:本地后端保持既有的按目标变更锁,因此版本检查-然后-rename 是串行化的,失败的更新看到 `FS_STALE_VERSION`。 + +进程内创建由同一按目标变更锁守卫:两个调用者以 `createIfAbsent` 竞争时串行化,一个创建成功,下一个看到目标已存在并收到 `FS_NOT_OBSERVED`。跨进程创建仅尽力而为;本地的 stat-then-rename 守卫无法在所有未来后端上提供可移植的排他创建保证。 + +跨进程写入是尽力新鲜度加原子替换:`mtime:size` 通常能捕获编辑器保存,但同一时刻相同大小的写入可能遗漏;原子性的 temp+rename 防止文件撕裂但不能防止所有丢失更新。 + +## 取代 + +本 RFC 逆转了 [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 的两项决策,并收窄了第三项: + +- 读后写/编辑策略从 `ctx.fs` 移出,进入 `dsh-fs-policy` 插件(在 `fs/*` 事件门控上)。 +- 文本读取不再返回后端编号的行记录或 `full`/`partial` 视图;授权基于版本新鲜度,因此窗口化读取在文件未变时即可授权编辑。 +- 字面编辑不再位于旧的 `applyEdit` API 之后(该 API 混合了后端变更与 seam 拥有的观测策略)。它作为 `editText` 保留为提供方原语,因为版本守卫 + 字面匹配 + 原子重写必须在提供方的变更临界区内保持一体,以确保正确的错误归因和并发行为。 + +保留的内容:接口/实现/消费方纪律、消费方不导入后端规则、后端定义的 target/version/display 元数据、原子本地写入,以及共享的 `FsError` 分类体系。 + +## 验证 + +`dsh-fs` 精确暴露 `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`(`stat` 返回 `FsInfo | undefined`,`writeText` 接受 `FsWriteIntent`),已删除的类型/原语不再存在;`dsh-fs-local` 不携带行、视图或 `formatReadBody` 逻辑;面向模型的 schema 逐字节未变。测试固定了以下行为:窗口化读取可以授权对未变文件的后续编辑;基于陈旧读取的编辑在尝试字面匹配之前报告 `FS_STALE_VERSION`;版本 CAS 行为得到保持;观测契约成立(通过 `read` 工具的读取记录 observed-state;直接的 `ctx.fs` 读取不记录);`dsh-fs-policy` 具有 HMR/dispose 覆盖率。 + +## 后续扩展 + +该 seam 后来由 [Add direct directory listing to the filesystem seam](../architecture/2026-07-03-filesystem-directory-listing-seam.md) 扩展了直接目录列表功能。该后续工作单独跟踪,以使本 RFC 的验收标准继续描述最初交付的 fsspec 风格改造。 + +## 曾考虑的替代方案 + +- **字节级 fsspec(`cat`/`open` 返回原始字节)**——否决:该 seam 刻意定位为文本存储,比字节级高半层,使 UTF-8 解码、二进制/NUL 拒绝和带守卫的文本变更在提供方内只实现一次,策略层从不接触原始字节,也不将陈旧检查与变更临界区分离。 +- **具体的 `ctx.fileContext` 方法服务**——本 RFC 最初的策略形态;由[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 改造为门控插件,使工具从不与策略产生方法耦合。 +- **将 `readPage` 和 `full`/`partial` 视图授权保留在提供方上**——改造前的形态,即「取代」一节所逆转的内容:视图完整性不是编辑安全所需的信号,版本新鲜度才是;视图规则使超过读取上限的大文件无法编辑。 + +## 后果 + +- 新增第四个 fs 包和一个新的插件层。这是有意为之:它是此前推迟的策略层,而非第二个抽象后端 seam。 +- 直接使用 `ctx.fs` 会绕过策略:直接的 `ctx.fs.readText` 不发出 `fs/observed`,因此在默认策略下,后续的 `edit` 会以 `FS_NOT_OBSERVED` 拒绝,直到通过 `read` 工具读取该文件。该失败是显式且有文档记录的。 +- 大文件行窗口化从后端移至 `dsh-tool-fs` 中的 `read` 工具;文本解码和二进制拒绝留在 `ctx.fs.streamText` 中,因此这只是窗口化逻辑的迁移,不是第二套文本 IO 实现。 +- 将 `editText` 保留在提供方 seam 上意味着每个后端都必须实现字面替换契约。这是有意为之:该操作不是纯存储,但陈旧守卫 + 字面匹配 + 原子重写是必须保持一体的单元,以确保正确的错误归因和并发行为。该契约应保持窄且仅限文本,以便未来后端可以原生实现或通过全文件重写实现。 +- 新鲜度允许在窗口化读取后执行全文件 `write`。这比旧的视图检查更弱,但避免了大文件无法编辑的问题;提示词引导仍然不鼓励盲目的全文件替换。 diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml new file mode 100644 index 0000000000..ca87af6f9a --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-02-remove-stream-chunk-mirror.md: 1ec633b09c8e53ae7145a49061aced191a9aa765 +2026-07-02-remove-stream-chunk-mirror.zh.md: 83674658d24621b12a866262bb58dde166bedf2d diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index a272dfe0b2..1ec633b09c 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -1,5 +1,7 @@ # RFC: Stop mirroring the token stream as an agent event +English | [中文](2026-07-02-remove-stream-chunk-mirror.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md new file mode 100644 index 0000000000..83674658d2 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -0,0 +1,47 @@ +# RFC:停止将 token 流镜像为 agent 事件 + +Status: implemented + +[English](2026-07-02-remove-stream-chunk-mirror.md) | 中文 + +## 问题 + +agent loop(智能体循环)将模型的每个 token 增量同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/loop.ts` 中,两者仅相隔一行: + +```ts ignore-check +const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) +chunkSeqs.push(chunkEvent.seq) +ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror +``` + +- 持久事件:`assistant/chunk: { turn, step, chunk }`。 +- 实时发射:`agent/stream-chunk(agent, turn, step, chunk)`——相同的 `StreamChunk`,相同的 `turn`/`step`。 + +实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 + +这与[边界镜像移除](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复如出一辙:消费方对同一个持久事实有两个真源,每次修改都必须同时触及两处。那份 RFC 将分片流推迟处理(「`assistant/chunk` 的持久化仍然是承重的,因此分片流后续可以作为镜像来评估,但那是一个独立的决策」),而非一并打包。本 RFC 就是那个独立的决策。 + +推迟所依赖的前提已经尘埃落定:分片持久化是权威的,且将保留。停止持久化分片、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 流。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 + +## 决策 + +从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化和回放已经使用的正是同一条流。`session/event` 是唯一的实时 transcript(文本记录)流(assistant 分片、轮次/步骤边界、工具活动、todo)。 + +**消费方。** 唯一重要的生产消费方——ACP 桥接层(`dsh-acp`,真正面向编辑器的流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其分片渲染被折叠进该监听器的 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立的监听器(`agent/stream-chunk` 和 `session/event`)之间共享,分片与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 + +## 范围 + +移除:`agent/stream-chunk`。 + +未触及: +- `assistant/chunk`(持久会话事件)——权威的 token 流,原样保留。本 RFC 移除的是实时镜像,而非持久化(持久化移除提案已被单独否决,见上文)。 +- `agent/steering`——本决策未触及(它是控制信号,不是 token 流)。其持久孪生事件是 `steering/message`,镜像发射由其自己的后续 RFC 移除:[移除 `agent/steering` 镜像发射](2026-07-04-remove-agent-steering-mirror.md)。 +- `agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`、`agent/session-start`——生命周期/控制事件,不是 transcript 数据,也没有持久副本。 + +## 曾考虑的替代方案 + +**移除持久化、仅保留瞬态实时流**——反向裁剪,已被[单独否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md):高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 流。这一点既已确定,实时发射就是配对中冗余的那一半。 + +## 后果 + +插件不再能从以 `Agent` 为首参的事件观察 token 增量。它应订阅 `session/event` 并过滤 `assistant/chunk`(如需 `Agent` 句柄,可从 `agent/created`/`agent/disposed` 构建的 session-id→agent 映射中恢复,与边界消费方的做法完全一致)。没有任何生产消费方在分片时需要实时的 `Agent`;这与边界镜像移除所做的权衡完全相同,是可接受的。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml new file mode 100644 index 0000000000..776f41a858 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-drop-image-content-block.md: 8222880b61c225c39a1e132353c5f343fb90cb4b +2026-07-04-drop-image-content-block.zh.md: d1379d69a0c5056cdfcc744182cd9b6c52f222d5 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md index 73645e8fda..8222880b61 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -1,5 +1,7 @@ # RFC: Drop the `image` content block until a path can honor it +English | [中文](2026-07-04-drop-image-content-block.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md new file mode 100644 index 0000000000..d1379d69a0 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md @@ -0,0 +1,29 @@ +# RFC:移除 `image` 内容块,直到有路径能真正处理它 + +Status: implemented + +[English](2026-07-04-drop-image-content-block.md) | 中文 + +## 问题 + +`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其**丢弃**:DeepSeek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP 编解码器既不声明 image prompt 能力、也不向外转发 image 块,并且对入站的 image prompt 内容直接**拒绝**;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇表声明了一种没有任何路径兑现的能力,这正是 `AGENTS.md` 防御性模式所警告的静默数据丢失形态。唯一的构造点是用于固定 skip/drop/estimate 分支的测试。 + +## 决策 + +移除 `ImageBlock`、其 map 条目,以及适配器、ACP 渲染和压缩中的 image 专用分支。在同一个变更中更新所属词汇文档与生成的引用。未知的扩展块仍然覆盖 default 分支,ACP 继续独立于 harness 词汇拒绝入站 image prompt 内容。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +当适配器、ACP 与压缩全部支持 image 时,`ContentBlockMap` 可以重新引入它。保留一个唯一实现是拒绝的核心类型,等于向外声明一个不可用的接口;移除则让生产者在编译期立即失败。 + +记录在案的回退方案(假设评审决定保留该槽位):保留 `ImageBlock`,但将每处静默跳过替换为显式拒绝,并在词汇文档中记录该策略——静默丢弃是唯一没有辩护者的状态。评审最终决定移除;此回退方案作为文档化的替代方案保留,以备该槽位在完整功能之前回归。 + +## 验证 + +RFC 记录之外没有任何地方构造 harness `ImageBlock`。ACP 独立的入站 image 拒绝仍有测试覆盖,而适配器、编解码器与压缩的 default 分支则通过插件定义的块类型覆盖。 + +## 后果 + +日后重新添加核心词汇类型会同时涉及多个包——但这种协调变更正是真正的多模态功能所需的形态(适配器映射、ACP 能力声明、压缩定价),而当前并没有什么需要保留的实现。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml new file mode 100644 index 0000000000..028162c527 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-drop-inert-request-knobs.md: d5484aa5ec64f89ce705ddd0a434c2c4dfbad460 +2026-07-04-drop-inert-request-knobs.zh.md: 877b073c4693f0c87b5f25003a1d043743b658fa diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md index 95a5a487b5..d5484aa5ec 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md @@ -1,5 +1,7 @@ # RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path +English | [中文](2026-07-04-drop-inert-request-knobs.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md new file mode 100644 index 0000000000..877b073c46 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md @@ -0,0 +1,35 @@ +# RFC:移除 `GenerateOptions.prefill` 与 `ToolSchema.strict`——无可用端到端路径的请求旋钮 + +[English](2026-07-04-drop-inert-request-knobs.md) | 中文 + +Status: implemented + +## 问题 + +两个请求契约旋钮贯穿了整条请求流水线,但都无法产生任何效果: + +- **`prefill`**(`packages/llm/llm/src/types.ts`)没有生产环境的赋值方:agent loop(智能体循环)组装的只有 `model`/`system`/`tools`/`messages` 加 `sessionId`/`signal`,上下文压缩(context compaction)后端只追加 `maxTokens`;而且**两个**适配器都拒绝它:`packages/llm/llm-deepseek/src/serialize.ts` 和 `packages/llm/llm-pi-ai/src/adapter.ts` 各自在 `prefill` 非 undefined 时抛出 `LlmError('UNSUPPORTED')`。该字段全部可观测行为就是两个 throw,各由一个适配器测试固定。DeepSeek 的 chat-prefix completion 是一个 Beta 功能,使用的 base URL 两个适配器都未指向。 +- **`strict`**(`ToolSchema`,同一文件)贯穿了 `DefineToolOptions`/`defineTool`(`packages/core/tools/src/schema.ts`)、注册表的 `schemas()` 白名单(`packages/core/tools/src/index.ts`)、deepseek 协议格式(wire format)映射(`packages/llm/llm-deepseek/src/serialize.ts`,其 wire-type 注释记录了 strict 模式需要适配器未使用的 `/beta` base URL)、`packages/llm/llm-pi-ai/src/adapter.ts` 中的逐工具 payload 修补,以及 tool-catalog 渲染器(`scripts/gen-tool-catalog.ts`)中的条件 `Strict:` 行。没有任何已发布的工具设置过它:在所有 `tool-*` 包 src 和 `examples/` 中 `rg` 搜索,`strict:` 的生产方为零;唯一的赋值方是 dsh-tools 单元测试。 + +两个旋钮在适配器间是对称的,因此移除时两个孪生适配器一并清理——[孪生适配器设计](../architecture/2026-06-13-twin-llm-adapters.md)不受影响。 + +## 决策 + +- 从 `GenerateOptions` 中移除 `prefill`,同时移除两个适配器的 UNSUPPORTED 守卫、固定这些 throw 的测试、[core.md](../../../core-data-structures/core.md) 中的粘贴行,以及适配器 README 中记录拒绝行为的行。实操手册(Cookbook)中的 UNSUPPORTED 指导([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md))改为泛化表述——你的提供方无法兑现的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的后果部分将 prefill 记录为「受生产方门控」而非「已有归属」,遵照 [implemented/AGENTS.md](../AGENTS.md)。 +- 从 `ToolSchema`、`DefineToolOptions`、`defineTool`、`schemas()` 白名单、deepseek 序列化器分支及其 wire-type 字段、以及 tool-catalog 渲染器的 `Strict:` 行中移除 `strict`。pi-ai 的 payload 修补简化为无条件擦除 pi-ai 自身的逐工具 strict 默认值(pi-ai 在每个序列化工具上打 `strict: false`;手写的孪生适配器不发送此字段,因此擦除逻辑为保持协议格式对等而保留,由其序列化器测试固定)。赋值测试和 core.md 粘贴行已移除;`GenerateOptions` 与 `ToolSchema` 在 `scripts/type-equiv.manifest.json` 中保留各自的行,因为两个类型本身仍然存在,只是少了一个字段。 + +本 RFC 有意**不**触及 `temperature`、`stop` 或 `maxTokens`:这些字段被两个适配器端到端地兑现,是 `agent/request` 上请求变更钩子插件的自然首选目标。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +「显式的 UNSUPPORTED throw 是诚实的契约行为」——但一个旋钮在两个孪生适配器中的唯一实现都是拒绝,它什么也不承诺;删除它反而升级了失败模式:意外的赋值从运行时 throw 变为编译错误。「strict schema 遵循是官方文档记录的提供方功能,且管道完整」——但一个旋钮在有已发布工具设置它**且**有端点兑现它之前,都不是产品表面;今天两者都不成立。二者各自随其第一个真实生产方回归:`prefill` 随实现了 chat-prefix completion 的适配器(以及对不支持它的适配器的明确策略)一起回来,`strict` 随需要它的工具和 beta 端点方案一起回来。 + +## 验证 + +`rg prefill` 仅返回 RFC 记录(本文与[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的 producer-gated 后果);在 tool-schema 范围内 `rg strict` 仅返回本 RFC、保留的 pi-ai 擦除逻辑,以及无关行文(如 `strictEqual`)。两个适配器的契约测试在移除守卫后通过,pi-ai 修补仍然擦除库的 strict 默认值——协议格式对等由其序列化器测试固定。 + +## 后果 + +已发布的钩子桥接不设置任何请求字段,而请求变更插件(`agent/request` waterfall(瀑布式事件)监听器)使用的是 `temperature`/`stop`(保留且可用),而非适配器拒绝的字段。如果 chat-prefix completion 或 strict 模式成为产品功能,重新添加将随适配器/端点工作一起落地,届时契约能说明实际发生了什么,而非「所有人都 throw」。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml new file mode 100644 index 0000000000..429f89095a --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-drop-unconsumed-web-observation-surface.md: c48ff5b80c916cd6cc04d6a8339a8555d05b0d40 +2026-07-04-drop-unconsumed-web-observation-surface.zh.md: ce8a108450ed9e9308066ab45b8f000dc20fde39 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md index 35868be2fd..c48ff5b80c 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md @@ -1,5 +1,7 @@ # RFC: Drop the unconsumed web observation surface — the `providers-change` event and the status methods +English | [中文](2026-07-04-drop-unconsumed-web-observation-surface.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md new file mode 100644 index 0000000000..ce8a108450 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md @@ -0,0 +1,34 @@ +# RFC:移除未被消费的 web 观测面——`providers-change` 事件与 status 方法 + +Status: implemented + +[English](2026-07-04-drop-unconsumed-web-observation-surface.md) | 中文 + +## 问题 + +`WebService` 暴露了一组没有任何生产代码观测的观测面: + +- **`web/providers-change`**(`packages/web/web/src/index.ts`)在每次 provider 注册和 dispose(资源释放)时声明并发射。每个注册 effect 的回滚 yield 被刻意排在 emit 之前,唯一目的是让一个抛异常的 change listener 能回退注册。该事件在包自身的两个单元测试之外没有任何 listener(其中一个测试的存在就是为了固定那个回滚顺序)。 +- **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一个包)没有任何生产调用方:`dsh-tool-web` 直接通过 `ctx.web.search()`/`fetch()` 执行,并将不可用状态以 seam 在执行时抛出的结构化 `WebError` 错误码呈现(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自身的测试。`packages/web/tool-web/README.md` 与 [architecture.md](../../../architecture.md) 中的行文声称工具「只读取聚合的 `searchStatus()`/`fetchStatus()`」——这种漂移之所以存活,仅仅因为没有什么机制会拿行文与调用点做比对。 + +seam 自身的设计使两个观测面都失去了消费方:工具注册跟随产品 ENABLEMENT 而非 provider 可用性(`packages/web/tool-web/src/index.ts`),provider 选择在执行时解析、从不缓存——因此没有需要失效的缓存、没有需要重算的注册集合,也没有调用方需要一个独立于「执行并路由结构化错误」的可用性探针。HMR(热模块替换)清理由 effect disposer 自身承载。 + +这与[移除未被消费的 `llm/adapter-change` 事件](../../implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md)如出一辙:那次从 `LlmService` 移除了相同的通知形态、相同的回滚先于 emit 机制,以及相同的 listener-throw 测试。该 RFC 的保留/裁剪判据——保留 `tools/change`(因为它有合理的面向用户的工具列表消费方),裁剪启动期后端注册表信号——把 web provider 注册表信号明确归入裁剪一侧;status 方法则是同一判断应用于拉取面而非推送面。 + +## 决策 + +移除注册表变更事件、聚合 status 方法与类型,以及它们的专属测试。provider 私有的 status 保留用于执行时选择。面向调用方的覆盖率现在断言成功执行或结构化的选择错误,web 相关文档描述该按需调用契约。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +web seam RFC 当初有意指定了两者——事件作为最小的 HMR 可见性信号,status 方法作为工具的聚合诊断——且未来的 provider 状态面板是可以想象的。但同一 RFC 的其他选择使它们失去了消费方:按需派生的选择与基于 enablement 的注册使得没有消费方**能**需要它们;已交付的工具展示了真实模式(执行并路由结构化错误);漂移的 README 语句表明承诺的消费方从未实现。按照 AGENTS.md 的原则「RFC 是提案,不是金科玉律」,这些正是该提案中被代码证明过度延伸的部分;未来的观测者重新引入它实际消费的最小信号或查询,由该消费方塑造其形态。 + +## 验证 + +`providers-change`、`searchStatus`、`fetchStatus` 和 `WebCapabilityStatus` 在 RFC 历史之外不再有任何拼写残留;catalog 是最新的(`verify-cordis-catalog` 绿色);注册/释放的 HMR 安全测试通过执行行为证明清理正确;tool-web README 与架构段落描述了工具实际拥有的执行时错误路由契约。 + +## 后果 + +未来如果有 provider 选择器 UI 或诊断面板需要变更通知或 status 查询,它会重新添加自己实际消费的最小观测面;相同的判断及其反转条件已记录在 LLM 先例中。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml new file mode 100644 index 0000000000..dfa01c3e89 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-fold-stdio-ui-helper.md: 8d26af190a957b424960519f77cbe132291c74de +2026-07-04-fold-stdio-ui-helper.zh.md: 879cce0d40e396b56f1a961b5ff190bab4fd70dd diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index d3775754b9..8d26af190a 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -1,5 +1,7 @@ # RFC: Fold the stdio UI helper into the stdio app +English | [中文](2026-07-04-fold-stdio-ui-helper.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md new file mode 100644 index 0000000000..879cce0d40 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md @@ -0,0 +1,28 @@ +# RFC:将 stdio UI 辅助模块折入 stdio 应用 + +Status: implemented + +[English](2026-07-04-fold-stdio-ui-helper.md) | 中文 + +## 问题 + +readline UI 曾是一个完整的包(`@deepseek-ai/dsh-ui-stdio`,位于 `packages/support/`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载该应用来使用 readline UI,从不自行组合这个辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest 与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 分组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在——`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被文档标注为非产品表面的 support 包。 + +这条边界带来的是包元数据、workspace 与 tsconfig 引用、module-graph 行、README 条目,以及 publint 表面——服务于一个并不可独立替换的辅助模块:stdio 应用的前门集群总是包含 readline UI,且没有其他东西能有意义地消费它。 + +## 决策 + +该辅助模块以终端通道插件的形式存在于 `@deepseek-ai/dsh-stdio` 中(`packages/ui/stdio/src/index.ts`):`createStdioChat`、其 `StdioRuntime` 测试 seam 及单元测试(`packages/ui/stdio/tests/stdio.spec.ts`、`readline.spec.ts`)一并迁入,因此 EOF 处理、渲染、dispose(资源释放)以及 piped-vs-TTY 行为在按文件覆盖率门禁下仍有单元测试覆盖,且无需劫持进程全局对象。该模块保持具名的 `name`/`inject`/`Config`/`apply` 导出形状——即应用通过 `ctx.plugin(uiStdio, …)` 挂载时消费的契约——而 `examples/echo-agent` 与 `examples/coding-agent` 中的 keyless Loader 路径冒烟测试继续证明组合树能通过真实 Loader 启动(stdio 包的插件形状单元测试套件固定了显式的 `unwrapExports` 断言,因为缺少 `inject` 的 bundle 会跳过一个意外的 default 导出而非崩溃)。 + +`packages/support/ui-stdio` 包已删除:manifest、tsconfig 引用、module-graph 行与 README 行均已清理;原先命名该包的文档注释(示例 e2e 模块文档、`packages/README.md`、support 与 todo README、[ui 分组 README](../../../../packages/ui/README.md))现在描述的是包内模块。 + +## 曾考虑的替代方案 + +### 为什么不将其提升到 `ui/`? + +提升可以解决 support 与产品之间的错位,同时保留包边界——但只有在 readline UI 是一个可独立替换的集成或拥有第二个组合方时才是正确选择,而消费方普查表明两者都不成立。结构化的 ACP 桥接保持独立包,因为它是产品协议表面,拥有自己的契约和快照层级;readline 辅助模块只是一个应用前门的脚手架。在正式发布前重新拆出的成本很低:如果未来有第二个产品应用需要 readline UI,届时再拆出,由那个消费方来塑造包契约。 + +## 后果 + +- stdio 应用完整拥有自己的前门;一个叶子 `cordis.yml` 仍然只加载一个应用包,演示的形状没有变化。 +- 未来如果有独立的终端 UI 需要将该辅助模块作为包使用,届时由那个第二消费方驱动重新引入,而非仓库为假设性的复用保留一条边界。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml new file mode 100644 index 0000000000..f710b62445 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-prune-producerless-vocabulary-variants.md: 271b97f6217a2694f36b7fe7339eab6176dba9e5 +2026-07-04-prune-producerless-vocabulary-variants.zh.md: 5d75c0327e2faf5e6e37f8db959509161beda702 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md index 5d40b166bd..271b97f621 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -1,5 +1,7 @@ # RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) +English | [中文](2026-07-04-prune-producerless-vocabulary-variants.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md new file mode 100644 index 0000000000..5d75c0327e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md @@ -0,0 +1,33 @@ +# RFC:清理无生产者的词汇变体(块缓存提示、`agent` 消息来源、`continuation` 轮次触发器) + +Status: implemented + +[English](2026-07-04-prune-producerless-vocabulary-variants.md) | 中文 + +## 问题 + +合并可扩展的词汇映射表设计上通过声明合并来增长,代码库已在 `TurnEndReasonMap`(`packages/core/session/src/types.ts`)上声明了准入策略:像 `refusal` 这样的变体「在适配器或循环首次发出它之前,有意不加入」。三个已声明的词汇项违反了这一策略——每个都既无生产者也无消费方,其中两个甚至没有测试: + +- **`CacheHint` 及其 `cache?: CacheHint` 块字段**,位于 `TextBlock`/`ToolResultBlock`(`packages/llm/llm/src/types.ts`;image block 上还有第三个同类字段,随 image block 一起移除——见[移除 image 的 RFC](2026-07-04-drop-image-content-block.md))。没有任何地方构造过带 `cache:` 的块——src、测试和文档粘贴全部搜索为空——两个适配器也都不读 `.cache`:DeepSeek 的 prompt 缓存是自动的,适配器只从响应中映射出 `prompt_cache_hit_tokens`,从不向请求中发送提示。这是 Anthropic 风格的 `cache_control` 接口面,却没有任何提供方能兑现它。 +- **`MessageSourceMap.agent`**(`{ kind: 'agent'; agentId: string }`,同一文件)。零个构造点,测试中也没有。它预期的生产者在上线时并未使用它:subagent 后端将父级的 prompt 发送给子级时不带 `source`,因此日志中记录为 `{ kind: 'user' }`,通用信封渲染器在插值 `source.kind` 时也从不按它路由。 +- **`TurnTriggerMap.continuation`**(`packages/core/session/src/types.ts`)。agent loop(智能体循环)在结构上不可能发出它——续写发生在一个轮次*内部*作为后续步骤,从不作为新轮次——循环只构造 `message` 和 `injection` 触发器。唯一的写入者是一个手工构建的测试 fixture(测试前置数据)(`packages/support/llm-replay/tests/llm-replay.spec.ts`),它只需要一个任意的非 message 触发器,`injection` 触发器同样满足需求;唯一的生产环境触发器读取者 ACP 桥接层只过滤 `kind === 'message'`。 + +## 决策 + +删除 `CacheHint`、其 `cache?` 块字段、`agent` 消息来源变体和 `continuation` 轮次触发器变体:已发布的词汇不再包含它们。llm-replay fixture 改用 `injection` 触发器(任何非 `message` 触发器都能满足其用途)。[core.md](../../../core-data-structures/core.md) 和 [session.md](../../../core-data-structures/session.md) 中的 type-equiv 粘贴与裁剪后的映射表一致——两个符号保留在 `scripts/type-equiv.manifest.json` 中,因为每个映射表只是少了一个成员——[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的「后果」部分将缓存提示记录为「受生产者门控」而非「已有归属」,遵照 [implemented/AGENTS.md](../AGENTS.md)。 + +每个变体在获得真正的生产者之日回归,这正是映射表设计上的增长方式:缓存功能连同传输它的适配器一起重新添加 `cache`;subagent 归属连同打标的后端和路由它的消费方一起重新添加 `agent`;真正启动新轮次的自动续写功能连同发出它的插件一起重新添加 `continuation`。 + +## 曾考虑的替代方案 + +### 为什么不保留它们? + +[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 将「缓存提示……已有归属」列为设计后果,预留的槽位确实能传达意图。但一个空槽位是契约面,每个实现和消费方都必须考虑它(我的适配器是否必须兑现 `cache`?我的渲染器是否必须路由 `agent` 来源?),而兄弟映射表自身的 JSDoc 已经拒绝了「无发出者的预留」——`refusal` 和 `max_turn_requests` 被标注为*当有东西首次发出它们时*再添加的变体,而非提前声明。对已声明但无生产者的变体执行同一标准,才能让词汇表有意义:如果它在映射表里,就一定有东西在生产它。 + +## 验证 + +`rg` 搜索 `CacheHint`、`agent` 消息来源的拼写和 `continuation` 触发器的拼写,只返回 RFC 记录(本文,以及[移除 image 的 RFC](2026-07-04-drop-image-content-block.md) 中关于 image block 自身 `cache` 字段的描述);llm-replay fixture 使用 `injection` 触发器断言相同的回放行为;核心数据结构粘贴与 type-equiv manifest(元数据清单)保持同步。 + +## 后果 + +没有运行时行为改变——本来就没有任何东西能构造这些值。镜像事件的移除([边界镜像 RFC](2026-06-20-remove-agent-boundary-mirror-events.md)、[流式分片镜像 RFC](2026-07-02-remove-stream-chunk-mirror.md))只涉及瞬态的 `agent/*` 事件,从不触及持久化词汇,因此不存在冲突。其他地方准入策略已经生效:`rejected`、`prompt/blocked` 和 `hook/invoked`/`hook/result` 各自都有活跃的生产者——本 RFC 将同一标准延伸到缺少生产者的三个变体。image block 自身的 `cache?` 字段属于[移除 image 的 RFC](2026-07-04-drop-image-content-block.md),随该块一起移除;本 RFC 覆盖的是保留下来的块类型上的两个字段。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml new file mode 100644 index 0000000000..3a4db54e54 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-prune-write-only-fs-surface.md: ac2cbcc282b848b26a3d5d327e0ab54612b5ac91 +2026-07-04-prune-write-only-fs-surface.zh.md: 799847aa77599a292c1aa48e150aae99fa130ae3 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md index 0fc11a1c17..ac2cbcc282 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md @@ -1,5 +1,7 @@ # RFC: Prune write-only fields and a dead routing knob from the fs seam +English | [中文](2026-07-04-prune-write-only-fs-surface.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md new file mode 100644 index 0000000000..799847aa77 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md @@ -0,0 +1,32 @@ +# RFC:从 fs seam 中移除只写字段与一个无效路由旋钮 + +Status: implemented + +[English](2026-07-04-prune-write-only-fs-surface.md) | 中文 + +## 问题 + +[fs seam 拆分](2026-06-26-fsspec-style-fs-seam.md)将读取路由与策略从后端移入 `dsh-tool-fs` 和 `dsh-fs-policy`。四处接口保留了拆分前的形态——每次调用都填充,却无人读取: + +1. **`dsh-fs-local` 中的 `STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize`**——*在本次变更之前已被"无硬编码可调参数"审计移除,该审计将路由阈值改为 `dsh-tool-fs` 的 `readStreamMinSize` 配置;此处记录是为了完整呈现整个裁剪。*原始位置(`packages/fs/fs-local/src/fsio.ts`,从 `packages/fs/fs-local/src/index.ts` 再导出):包括 fs-local 自身源码和测试在内,全仓库零读取者。后端不做读取路由——`readWholeText`/`streamWholeText` 是调用方自行选择的独立原语——真正的路由常量在消费方(`packages/fs/tool-fs/src/read.ts`,与 `info.size` 比较)。10 MiB 这个事实有两份镜像;后端那份是死代码,而该旋钮的 JSDoc 声称提供一个并不存在的"读取路由"覆盖。 +2. **`FsTarget.inputPath`**(`packages/fs/fs/src/types.ts`):每个后端和每个测试 fake 都必须编造一个"仅用于诊断"的值,而生产环境零读取者——策略插件和所有错误消息使用的是 `targetKey`/`displayPath`。`listDir` 的生产者暴露了语义摇摆:目录子项拿到的是裸条目名,这不是任何人的"输入路径"。 +3. **`FsEditOutcome.replacements` + `.replaceAll`**(`packages/fs/fs/src/types.ts`):`replacements` 生产环境零读取者(单匹配策略本身保留——它由后端内部的 `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` 抛出强制执行,错误消息保留了内部计数);`replaceAll` 仅被 `packages/fs/tool-fs/src/edit.ts` 中的 `formatEditOutput` 读取——作为工具已持有的 `replace_all` 参数的回声。精简后,`FsEditOutcome` 变为 `{ version, before, after }`,与 `FsWriteOutcome` 中真正由后端发现的字段对齐。 +4. **`FileReadOutcome.limit` + `.version`**(`packages/fs/tool-fs/src/read-render.ts`):由读取工具填充,但 `formatReadOutput` 只渲染 `offset`/`lines`/`totalLines`/`truncatedByBytes`,而 `fs/observed` 事件直接使用 `info.version`,不使用 outcome 的副本。 + +## 决策 + +删除 fs-local 常量及其再导出和 `streamMinSize` 旋钮(`FsIoInternals` 中剩余的旋钮确实被原子写入测试使用);从 `FsTarget` 中移除 `inputPath`;将 `FsEditOutcome` 精简为 `{ version, before, after }`,并将 `replaceAll` 从解析后的参数传给 `formatEditOutput`;从 `FileReadOutcome` 中移除 `limit`/`version`。[filesystem.md](../../../core-data-structures/filesystem.md) 中的粘贴内容、`packages/fs/fs/README.md`,以及那些不得不编造被移除字段的测试 fake 随类型一起精简。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +未来的权限/隔离层可能需要解析前的路径来生成错误文本——但它需要的是*请求*,每个调用点仍然持有请求。"替换了 N 处"可能成为面向模型的文本——那是需要时再设计的行为变更,且后端内部的计数为其错误消息保留着。读取页脚可能展示 `limit`——页脚展示的一切已经可以从 `lines`/`totalLines` 推导。与此同时,当前和未来的每个后端(远程、原生)都必须编造无人消费的协议格式(wire format)字段,每个测试 fake 都必须满足它们。 + +## 验证 + +被移除的接口已不存在——`dsh-fs-local` 中的 `STREAM_MIN_SIZE`/`streamMinSize`、`FsTarget.inputPath`、`FsEditOutcome.replacements`/`.replaceAll`、`FileReadOutcome.limit`/`.version`——而请求侧的 `replaceAll`(`FsEditRequest`)和其他 outcome 类型上的 version 字段未受影响;测试 fake 随类型一起精简。`formatEditOutput` 在 `replace_all` 两个分支下的输出文本不变,因此没有快照 golden 被搅动。 + +## 后果 + +后端不增加新义务;它们卸下了四个无人消费的字段。fs 发现工作(glob/grep 工具)触及相同的 `dsh-fs` 类型文件——这是文本层面而非设计层面的重叠,可以机械地解决。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml new file mode 100644 index 0000000000..1e1c22f3c1 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-remove-agent-steering-mirror.md: 311f0f8ffd278adf71a35617b900d4d16037055a +2026-07-04-remove-agent-steering-mirror.zh.md: 4ceb31a0263a7c386ceed071d3f0db315c7a9f23 diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md index a0a383b255..311f0f8ffd 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md @@ -1,5 +1,7 @@ # RFC: Remove the `agent/steering` mirror emit +English | [中文](2026-07-04-remove-agent-steering-mirror.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md new file mode 100644 index 0000000000..4ceb31a026 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md @@ -0,0 +1,33 @@ +# RFC:移除 `agent/steering` 镜像事件发射 + +[English](2026-07-04-remove-agent-steering-mirror.md) | 中文 + +Status: implemented + +## 问题 + +`agent/steering` 是最后一个仍存在的、对持久会话事件的瞬态镜像。循环的 steering 排空逻辑先追加持久事件 `steering/message { turn, content, source }`,紧接着下一行就发射 `agent/steering(agent, turn, content, source)`——同一个事实以 fire-and-forget 事件的形式重复一遍(`packages/core/agent-loop/src/loop.ts`,`drainSteering`)。它在生产环境中没有任何监听者:唯一的订阅方是一个循环回归测试,断言该发射携带了 `source`——而这个事实在上一行的持久事件中已经记录。 + +`agent/steering` 以相同的 payload 复制了紧邻其前的持久事件 `steering/message`。`agent/queued` 则保留为纯 live 信号,因为它在持久化之前触发,覆盖了可能在进入日志前被取消的工作。 + +steering(中途引导)承载着真实的生产流量:钩子桥的轮次续行决策通过 `inbox.steer()` 注入原因,落地为持久的 `steering/message` 事件,钩子矩阵的 golden 文件固定了这些事件。所有这些消费方观察的都是持久事件,没有任何消费方观察镜像。 + +## 决策 + +`agent/steering` 从 agent 事件分类体系中移除:`packages/core/agent/src/types.ts` 中的声明(及其在 live-events JSDoc 列表中的提及)、`drainSteering` 中的发射(随之移除的还有当时已无用的 `ctx` 参数)、`packages/core/agent/README.md` 中的对应行,以及循环伪代码块中的发射行(`packages/core/agent-loop/src/loop.ts` 模块文档与 [architecture.md](../../../architecture.md));Cordis catalog 重新生成后不再包含它。唯一的回归测试改为在持久事件 `steering/message` 上固定 source 保持——它所固定的事实存在于日志中。 + +三份已实施的 RFC 曾声明保留该事件,每份均按 [implemented/AGENTS.md](../AGENTS.md) 修订,指向本 RFC 作为移除记录:[boundary RFC](2026-06-20-remove-agent-boundary-mirror-events.md) 的保留列表条目、[stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md) 的范围条款,以及 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 的瞬态发射枚举。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +"它是控制信号,不是边界事件"——但分类体系的实际区分维度是「镜像 vs 纯 live」,而非「控制 vs 边界」,而这个事件属于镜像。需要入队时通知的消费方有 `agent/queued`(带 steering flag);需要排空时通知的消费方本质上是在请求 `steering/message` 被追加的那一刻,而 `session/event` 以相同 payload 加上持久性提供了这一点。被否决的 [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) 捍卫的是 steering **能力**——`steer()`、持久事件、续行强制——本次移除对这些全部不动。 + +## 验证 + +`agent/steering` 这一拼写仅存在于 RFC 行文中(本 RFC、上述三份修订后的 RFC,以及冻结的[被否决 steering 能力 RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md),其文本记录了它所拒绝的提案);catalog 已重新生成;重定向后的测试在 `steering/message` 上固定 source 保持。 + +## 后果 + +没有需要迁移的生产监听者。两种 live 通知需求都保留了归属:入队时通知归 `agent/queued`(带 `steering` flag),排空时通知归 `session/event`(持久的 `steering/message` 落地时触发)。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml new file mode 100644 index 0000000000..16316292cd --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-share-app-bin-boot-glue.md: afaa61fe909f3dbf900337518969410780020a88 +2026-07-04-share-app-bin-boot-glue.zh.md: 2a022fbb8dcdf6977ede81780a87c570715ce441 diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md index 79abfab2a0..afaa61fe90 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -1,5 +1,7 @@ # RFC: Share the app bins' boot glue instead of maintaining twin copies +English | [中文](2026-07-04-share-app-bin-boot-glue.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md new file mode 100644 index 0000000000..2a022fbb8d --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md @@ -0,0 +1,27 @@ +# RFC:共享应用 bin 的启动胶水代码,不再维护两份副本 + +Status: implemented + +[English](2026-07-04-share-app-bin-boot-glue.md) | 中文 + +## 问题 + +stdio 和 ACP bin 各自重复了环境加载、fail-loud 处理、入口校验与启动逻辑,包括微妙的 Loader 失败行为。两份副本已经发生漂移,且位于自执行文件中、被排除在单元测试覆盖率之外,导致其中的辅助导出无法被复用。 + +## 决策 + +辅助逻辑只存在一处:[`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot)(`packages/ui/app-boot`,归入 `ui` 组,因为 bin 是已发布产物,其运行时依赖本身也必须是已发布的,而非 `support/`)。包含:`resolveConfigPath`(快照感知,两个 bin 共用的唯一路径解析器)、`loadEnv`、`installFailLoud`、`assertEntriesLoaded` 和 `boot`,每个函数都按 bin 的诊断前缀参数化,并在其副作用 seam(warn sink、process 切片)处可注入,使单元测试套件能覆盖每个分支——包括 `boot()` 在进程内驱动真实 Loader、使用相对路径 specifier 的配置,涵盖已就绪树的正常路径和无 fiber 入口的拒绝路径。该包(package)启用了逐文件 100% 覆盖率门禁;Loader 失败的经验知识只有一个归属地。 + +每个 `bin.ts` 是一个精简的自执行组合:在共享辅助逻辑之上叠加各自应用特有的生命周期(ACP bin:replay 模式下跳过环境加载与 stdin-EOF dispose;stdio bin:无额外逻辑)。bin 文件仍然被排除在覆盖率之外且不导出任何内容;已发布产物的防护措施不变——built-bin 冒烟测试仍然在一个 node_modules 形状的临时目录下用原生 node 运行每个 bin(现在也 symlink 了 `ui/app-boot`),并仍然断言缺少配置时的非零退出码,遵循「真实入口路径意味着已发布产物」的防御模式。[extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md) 中关于 bin 归属的事实已相应修订。 + +## 曾考虑的替代方案 + +### 为什么不保留重复? + +bin 被定位为独立拥有的已发布产物,而新增一个包有固定开销(manifest、README、tsconfig reference、publint 表面积),与去重的代码行数相当。但创建 bin 的那份 RFC 从未权衡过应用间共享——它把三个示例 `start.ts` 副本合并**进**了 bin 就止步了;漂移是已观察到的事实;而覆盖率缺口的论点独立于去重论点:这是仓库中唯一被豁免于逐文件 100% 门禁的非平凡运行时逻辑。记录在案的回退方案(仅将纯逻辑提取为各应用模块)可以结束豁免,但会保留两个经验知识归属地。 + +## 后果 + +- 启动胶水代码的变更(新增守卫、修复解析)只需落地一次,两个已发布 bin 自动继承;bin 之间不会再次漂移。 +- `dsh-app-boot` 保持依赖精简(cordis + loader/include 对)——它是启动机制,不是应用接口。 +- bin 自身的文件是近乎平凡的组合;所有带分支的逻辑都在覆盖率门禁之下。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml new file mode 100644 index 0000000000..38cd3003a8 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-tighten-hook-protocol-contract.md: df438516b836315902378afe7f4fd09e512c0966 +2026-07-04-tighten-hook-protocol-contract.zh.md: decc6ba86131f6d1930eb51a1267a9668d91dcb2 diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md index 72172144d0..df438516b8 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -1,5 +1,7 @@ # RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics +English | [中文](2026-07-04-tighten-hook-protocol-contract.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md new file mode 100644 index 0000000000..decc6ba861 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md @@ -0,0 +1,32 @@ +# RFC:收紧 hook 协议契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 + +[English](2026-07-04-tighten-hook-protocol-contract.md) | 中文 + +Status: implemented + +## 问题 + +`dsh-hook-protocol`/bridge 契约中有四处遗漏了 [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) 所记录的纪律——该 RFC 因缺乏消费方而移除了 `agentType` 生命周期字段,以下四处未通过同样的检验: + +1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有任何生产者——bridge 只打 `'claude'` 和 `'codex'` 标记;唯一的 `'native'` 构造出现在 lib 自身的单元测试中。该字段自己的 JSDoc 将 `dialect` 定义为「执行它的 bridge」,而 native 不是 bridge:[interception-seams RFC](../feature/2026-06-30-interception-seams.md) 记录了 native 钩子不是一个 package,且「native 插件已经可以直接使用类型化的 Decisions」而无需持久化的 hook 日志;旗舰 native 插件的工作示例也正是如此断言的(完全没有 `hook/*` 事件)。 +2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上都被丢弃:没有 bridge 分支、没有 merge fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中,它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本没有什么可 suppress 的:hook 的 stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此 hook 作者设置 `suppressOutput: true` 得到的是无声的空操作,连 warn 都没有。 +3. **`defaultTimeoutMs` 在两个 bridge 配置中被双重默认,使用浮动字面量**——一个 schema `.default(600_000)` 加一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),每个 bridge 为同一个协议级常量提供两个归属,两个 bridge 可能在共享默认值上悄然分歧。*本提案最初的补救——彻底删除该配置项——被 no-hardcoded-tunables 审计取代,后者保留了该配置项作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下需要修复的是字面量的归属。* +4. **`hook/result` 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。** `summarize()`——stderr 截断规则——在 `packages/hooks/hooks-claude/src/index.ts` 和 `packages/hooks/hooks-codex/src/index.ts` 中逐字节相同,decision 字符串规则 `output.decision ?? (output.continue === false ? 'stop' : 'pass')` 也是如此;然而 `dsh-hook-protocol` 声明了 `hook/result`、将 `stderrSummary` 文档化为「已截断」却不拥有截断逻辑,将 decision 值文档化却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享的持久化事件的语义就会悄然分叉。 + +## 决策 + +`HookDialect` 是封闭的 bridge 集合,`'claude' | 'codex'`;`HookOutput` 移除不受支持的 `suppressOutput`。`hook/result.durationMs` 保留为持久化的审计计时,仅在快照中做归一化。参考默认值各只存在一处:`DEFAULT_HOOK_TIMEOUT_MS` 和 `DEFAULT_STDERR_SUMMARY_MAX_CHARS`。`HookResultRecord` 与 `appendHookResult` 为两个 bridge 统一拥有 stderr 摘要化和 decision 推导逻辑。`BLOCKING_EXIT_CODE` 为 codec 内部常量。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +不受支持的词汇(vocabulary)可以在真正有消费方时回归。`durationMs` 保留,因为持久化的审计计时独立于当前是否有读取者而有价值。Bridge 特有的 payload 构造留在各自 bridge 中,而共享的持久化事件归一化属于协议库。 + +## 验证 + +`HookDialect` 只包含 Claude 和 Codex,`suppressOutput` 在源码、解析字段文档和归一化逻辑中均不存在。`durationMs` 保留在事件和 fixture(测试前置数据)中,回放时做擦除。`600_000` 和 `500` 默认值各只在协议库中出现一次,per-hook 超时覆盖仍然生效,两个 bridge 的测试套件都验证了库拥有的 stderr 截断和 decision 规则。 + +## 后果 + +`dialect`、`suppressOutput`、可调参数与语义变更在协议格式(wire format)和 golden 文件上不可见。代价是 `dsh-hook-protocol` 和两个 bridge 的代码变动——在预发布阶段这很廉价,且比让持久化事件语义的两份副本各自老化要廉价得多。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml new file mode 100644 index 0000000000..f91d18a48e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-trim-acp-bridge-unreachable-surface.md: 6decb494dcbfd348777577002187007597a8c374 +2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 77e22f75c96704ee0379c45d2ed23167df047324 diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md index 191ddd833f..6decb494dc 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -1,5 +1,7 @@ # RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback +English | [中文](2026-07-04-trim-acp-bridge-unreachable-surface.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md new file mode 100644 index 0000000000..77e22f75c9 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md @@ -0,0 +1,26 @@ +# RFC:裁剪不可达的 ACP bridge 接口——品牌旋钮与 kind 嗅探回退 + +Status: implemented + +[English](2026-07-04-trim-acp-bridge-unreachable-surface.md) | 中文 + +## 问题 + +`dsh-acp` 有两处接口在任何已交付的配置下都不可达: + +1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已交付的 app 包(package)只向 bridge 传入 `{ model }`(`packages/examples/acp-demo/src/index.ts`),因此唯一的生产配置面——叶子 `cordis.yml`——根本无法设置这两个旋钮;它们只能通过直接挂载 bridge 来设置,而只有单元测试这样做。所有快照 golden(包括 hook-matrix 场景)都固定了 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对字段还带着一条活跃的 `TODO(double-default)`:字面量存在两份(schema 的 `.default(...)` 加 `??` 回退),TODO 要求选定一个归属。 +2. **`toolKindFor` 名称启发式**(同一文件)在通用回退路径中对 `bash*`/`read*`/`write`/`edit*` 工具名做了特殊处理。自 [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) 以来,这些分支匹配到的每个第一方工具都自带 `presentCall` 并携带其 kind,而没有 presenter 的生产工具(`subagent`、`subagent_fork`)本来就落入 `other`。这些分支在生产中可达的唯一情况是:某个工具拒绝自行呈现其调用——`presentCall` 抛出异常(containment 回退),或模型参数未通过工具 schema 导致 `defineTool` 的 `presentCall` 包装层返回 `undefined`(例如 `bash` 调用缺少必需的 `description`)——而 bridge 自身的模块文档明确声明了该启发式所违反的设计规则:「bridge 从不对工具名做特殊处理」。 + +## 决策 + +在初始化时硬编码现有的握手标识 `{ name: 'deepseek-harness-acp', version: '0.0.1' }`,移除不可达的配置字段与重复默认值。在两处 presenter 回退中,将 `toolKindFor` 替换为中性的 `'other'`。正常的第一方呈现不受影响;格式错误或失败的呈现现在渲染一张诚实的通用卡片,而非从工具名推断 kind。初始化测试和快照固定握手标识;只有 `hook-codex-posttool-block` 中格式错误的调用改变了回退卡片的 kind。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +品牌旋钮可以在 app 包将其暴露给部署时回归。从未知工具名推断呈现方式违反了 render-intent 契约;中性回退卡片还能为格式错误的调用和损坏的 presenter 保留原始输入。 + +## 后果 + +除上述回退渲染的取舍外无其他影响——退化路径下,中性卡片比推断出的第一方卡片更易于诊断。 diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml new file mode 100644 index 0000000000..b423b59448 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-drop-unconsumed-skill-provider-events.md: 5ec9d201939b8f58334647353f599361bd2e58a0 +2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 8ab2a5f55b2a1eed676b28a1ca7804d6237f63fd diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md index 0907a63417..5ec9d20193 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md @@ -1,5 +1,7 @@ # RFC: Drop unconsumed skill provider events +English | [中文](2026-07-12-drop-unconsumed-skill-provider-events.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md new file mode 100644 index 0000000000..8ab2a5f55b --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md @@ -0,0 +1,29 @@ +# RFC:移除无消费方的 skill 提供方事件 + +Status: implemented + +[English](2026-07-12-drop-unconsumed-skill-provider-events.md) | 中文 + +## 问题 + +skill(技能)注册表产出了两个通知事件,但在生产代码中没有任何监听方。生成的生产者/消费方矩阵以及精确的事件名搜索表明,`skill/provider-added` 和 `skill/provider-removed` 只出现在声明、发射点、测试、生成目录和行文中。 + +skill 发现按需读取当前提供方映射表,提供方注册同步清除已完成的目录缓存,await 后的修订检查防止陈旧的发现结果进入缓存。没有兄弟插件通过这些事件等待 skill 提供方,不同于 `subagent/provider-added` 的实际消费方(它容忍兄弟并发加载)。 + +`tools/change` 和 `system-prompt/change` 明确不在本提案范围内。既有的简化决策将它们保留为面向实时工具和提示词 UI 的有意观测点,且自引用的已挂载插件已在使用 `tools/change`。本提案同样不改动 `subagent/provider-added`/`removed`,因为 `tool-subagent` 有生产级的生命周期消费方。 + +## 决策 + +skill 注册表不再声明和发射提供方成员变更事件。提供方的注册与 dispose(资源释放)仍为 effect 拥有的直接状态变更,同步使已完成的目录缓存失效;查找与发现按需读取当前提供方映射表。测试通过提供方查找和收集的输出来观察清理行为,而非生命周期通知。 + +生成的事件目录、API 目录与生产者/消费方矩阵不再包含已删除的通知。skill 系统 RFC 和包文档通过 effect 拥有的直接状态及缓存失效契约来描述注册行为。 + +## 曾考虑的替代方案 + +**为未来插件保留 skill 提供方通知。** 第三方插件可能想观察提供方的可用性,但直接提供方注册与按需查找才是扩展契约;当前没有消费方需要推送信号。如果未来出现兄弟加载竞态,可以像 subagent 注册表那样引入一个带有该消费方实际所需的身份与就绪语义的通知。 + +## 后果 + +生成的事件矩阵中不再有 `skill/provider-added` 或 `skill/provider-removed` 的行。skill 发现、直接运行时注册、提供方 effect 回滚/dispose、缓存失效与注册表查找清理均保留;随事件一起消失的是监听器触发的回滚。`tools/change`、`system-prompt/change` 以及已被消费的 subagent 提供方生命周期事件不受影响。 + +预发布消费方失去 skill 提供方观测点,但仍保留贡献 skill 的两种方式:直接运行时注册与提供方注册。未来若有消费方需要实时的提供方可用性信息,须新增一个带有其实际所需的身份与就绪语义的专用通知。 diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml new file mode 100644 index 0000000000..6c0c1f9108 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-prune-unused-web-seam-fields.md: b4773c2706cf6d18ea4bb96720cd6c932cdf8942 +2026-07-12-prune-unused-web-seam-fields.zh.md: 1beb9e597c4b990f2c4aaaf3e34e71027c3f3fb7 diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md index 8ece4214b7..b4773c2706 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md @@ -1,5 +1,7 @@ # RFC: Prune unused web seam fields +English | [中文](2026-07-12-prune-unused-web-seam-fields.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md new file mode 100644 index 0000000000..1beb9e597c --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md @@ -0,0 +1,27 @@ +# RFC:裁剪 web seam 中未使用的字段 + +Status: implemented + +[English](2026-07-12-prune-unused-web-seam-fields.md) | 中文 + +## 问题 + +web 能力携带了一组 request/result/status 值,每个已交付的实现都填充了它们,但没有生产消费方读取。`WebSearchResult.providerId`、`query` 和 `WebFetchResult.providerId` 是结果回显;`tool-web` 只格式化 content/sources/truncation 或 final URL/status/body/truncation,其他运行时也不读取这些字段。搜索提供方返回 `WebProviderStatus.reason`,但可用性检查只看 `available`,并有意输出一条通用的不可用诊断。 + +`WebFetchRequest.timeoutMs` 同样没有生产调用方设置。`tool-web` 只提供 URL,用工具定义的超时加 `exec.signal` 作为调用方截止时间,并依赖本地提供方的配置默认值作为兜底。这个未使用的按请求超时覆盖迫使 `web-fetch-local` 暴露 `maxTimeoutMs`、钳位两个超时源,并为没有产品路径能选中的优先级规则编写文档和测试。`WebExecContext` 则是另一个单字段包装层:每个调用方分配 `{ signal }`,每个提供方立即解包 `exec?.signal`;不存在第二个执行控制字段。 + +## 决策 + +web seam 省略搜索/抓取的 `providerId` 结果回显和搜索 `query` 回显;调用方本身已持有请求和提供方选择信息。提供方以返回布尔值的方法暴露可用性。抓取请求不再有按请求超时或 `maxTimeoutMs` 钳位;本地提供方保留其可配置的默认超时,工具保留自身的截止时间。提供方方法接收一个直接的可选 `AbortSignal`,而非单字段的 `WebExecContext` 包装层。 + +所有 web 实现和面向模型的工具使用更小的契约。接口/实现/消费方的包拆分、提供方选择、来源引用、最终 URL/状态数据、截断报告与安全限制保持不变。 + +## 曾考虑的替代方案 + +**保留自描述结果、按请求截止时间和可扩展的执行上下文对象。** 结果回显可以帮助通用遥测,请求超时可以帮助受信的编程调用方,包装层对象为未来的控制留出空间。但这样的消费方或第二字段并不存在;在每个提供方中携带重复的身份信息、第二套截止时间策略以及包装/解包管道,使当前契约更难实现和解释。如果遥测或按调用的预算控制到来,它应当定义哪个截止时间获胜、在哪里观测提供方身份,以及多个控制是否足以证明上下文对象的存在。 + +## 后果 + +保留下来的每个 web request/result 字段都被生产代码消费或为执行提供方请求所必需。工具可见的搜索/抓取输出、提供方回退、中止行为、配置的超时兜底、截断与引用仍被覆盖,无需请求超时优先级分支或执行上下文包装层。 + +预发布的编程调用方失去结果来源回显和按请求的抓取截止时间。提供方仍有部署可配置的超时并尊重取消信号,因此这次精简移除的是可配置性而非安全边界。 diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml new file mode 100644 index 0000000000..34850593d6 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-property-based-testing.md: 169989746ea5114b1f35e7ebe35a02e8aeb0f782 +2026-06-11-property-based-testing.zh.md: 9e1532c02bb2c46c40577af7275d6aabbb2f9a4f diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 67404ab49a..169989746e 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -1,5 +1,7 @@ # RFC: Property-based testing for protocol-shaped code +English | [中文](2026-06-11-property-based-testing.zh.md) + Status: implemented > Merges the original proposal and the decision record for one topic. It found a real BlockAssembler duplicate-`block-end` bug on first run. diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md new file mode 100644 index 0000000000..9e1532c02b --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md @@ -0,0 +1,29 @@ +# RFC:对协议形态代码进行基于属性的测试 + +Status: implemented + +[English](2026-06-11-property-based-testing.md) | 中文 + +> 将原始提案与决策记录合并为一篇。首次运行即发现了 BlockAssembler 的重复 `block-end` 真实 bug。 + +## 问题 + +基于示例的测试只能固定我们想到的用例。harness 的核心是协议形态的代码:分片流、事件日志、schema 转换、收件箱调度。这类代码的输入空间是组合爆炸的,有趣的 bug 藏在没人写过示例的交错序列里。佐证:一个 block 组装的排序 bug 曾在 happy path 100% 行覆盖率下存活。逐文件 100% 覆盖率只能证明每行都跑过,不能证明每种交错都正确。 + +## 决策 + +引入 `fast-check`(根 devDependency),在每个协议形态的包中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。(原始提案还草拟了一个夜间 CI job,以 100 倍迭代运行;该部分未交付——属性测试套件仅在常规 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。) + +- **dsh-llm / BlockAssembler:** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start)。不变式:`blocks()` 数量 ≤ 出现过的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且只产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无 `finish` 分片时默认为 `{kind:'stop'}`。 +- **dsh-session:** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致;seq 严格单调递增;非消息事件不影响派生历史;派生内容与日志解耦。 +- **dsh-tools:** 任意 `SchemaSpec`。不变式:JSON Schema 的 `required` 等于每层 `required:true` 的键集合;转换是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`,定向破坏(删除 required 键、顶层非 object)被拒绝。这封堵了 validator 与 `InferArgs` 漂移的风险。 +- **dsh-agent-loop:** 任意发送调度,对接一个永不耗尽的适配器,通过 `agent/status` settle 信号驱动(无挂钟 sleep)。不变式:无消息丢失;轮次编号严格递增;状态转换始终在合法状态机上。 + +## 后果 + +- 生成器质量是价值杠杆——生成器偏向小索引池和短字符串,使碰撞与交错频繁出现。 +- **已经产出回报:** BlockAssembler 的流测试发现了一个真实 bug——同一索引的重复 `block-end` 覆写了已刷出的块,导致流式前缀与最终 `blocks()` 不一致。已修复(首次关闭生效,与既有的滞后分片规则一致),并附带专门的回归测试。 +- 属性测试因超时而 flake 是一个发现,不应重试了事。agent loop 的属性测试在设计上是确定性的(通过 `agent/status` settle),因此挂起即为真实缺陷。 +- 属性测试是示例测试的补充而非替代;示例测试固定特定分支,服务于 100% 覆盖率门禁。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml new file mode 100644 index 0000000000..8014cd6b63 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-19-acp-snapshot-tests.md: 0b93c99932a33bca9945dd88ce45f4a1e100ccfc +2026-06-19-acp-snapshot-tests.zh.md: dc0aeb102039d3261ad6bbbb21321ca2b6ef5ec3 diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 5f05c92317..0b93c99932 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -1,5 +1,7 @@ # RFC: ACP snapshot tests — record-once / replay-deterministic +English | [中文](2026-06-19-acp-snapshot-tests.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md new file mode 100644 index 0000000000..dc0aeb1020 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -0,0 +1,82 @@ +# RFC:ACP 快照测试——一次录制 / 确定性回放 + +Status: implemented + +[English](2026-06-19-acp-snapshot-tests.md) | 中文 + +## 问题 + +单元测试无法覆盖完整的 ACP 子进程 transcript(文本记录),而真实 API 测试既不确定又依赖密钥。因此,面向编辑器的 `session/update` 输出可能在单元覆盖率全绿的情况下发生回归,正如 [default-export 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)所展示的那样。 + +全 transcript 测试的阻塞点在于模型:agent(智能体)的输出由非确定性的 LLM(大语言模型)驱动,而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度,同时具备 fixture(测试前置数据)的确定性。 + +本 RFC 记录了添加第三层测试——**快照测试**——的决策,以及使其确定、CI 中无需密钥且维护成本低的设计选择。 + +## 决策 + +快照测试启动真实的 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将归一化后的输出与已提交的 golden 文件比对。一次从真实 API 录制的会话日志为后续所有模型流提供数据。fixture 就是产品正常持久化的 JSONL。 + +### fixture 即持久化的会话 JSONL + +每个场景的 `session.jsonl` 从一次真实运行中采集。`assistant/chunk` 事件重现模型流;工具、消息和边界事件捕获 harness 行为。一份普通的会话产物因此同时充当回放源和行为 golden。 + +### 回放从日志推导模型脚本 + +`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。循环每步发起一次流调用,因此分组是精确的,且无需特殊处理即可包含 error finish chunk。 + +### 内存中的回放条目遵守完整的 LLM 契约 + +`deriveReplayScript` 产出一组 `ReplayEntry`,即回放监听器按位置服务的内存单元: + +``` +{ kind: 'chunks', chunks: StreamChunk[] } +| { kind: 'throw', chunks: StreamChunk[], message: string, code: string, status?: number } +| { kind: 'hang' } +``` + +日志推导出 chunk 条目。流开始前的抛出和挂起没有可重建的 chunk 表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀 chunk 以表示流中途失败。显式覆盖避免了从有损的 turn-end reason 推断适配器行为。 + +### 位置式回放,单个在途流 + +回放是位置式的,因此每个场景只允许一个在途模型流。并发会话快照需要按请求键索引的条目。调用顺序变化需要重新录制,fixture 缺失或耗尽时会大声失败。 + +### 录制采集日志;无密钥回放需要无提供方的配置 + +录制使用真实的 `llm-deepseek` 适配器和 JSONL 持久化后端运行场景,然后将产出的 `.jsonl` 复制到场景目录。逐事件追加是持久的,但 harness 在采集前会优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),确保最终事件已刷出。`llm-replay` 本身不做录制——它只负责回放。 + +回放使用 `cordis.snapshot.yml` 覆盖层,将真实适配器替换为 `llm-replay`,同时保留活跃的组合。录制使用普通配置和 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此一个意外存在的 API key 不会触发真实调用。见[单一源配置 RFC](2026-07-04-single-source-acp-replay-config.md)。 + +### 两个表面:归一化后比对 + +快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的: + +1. **stdout transcript**——编辑器看到的帧化 `session/update` JSON-RPC。捕获 ACP bridge 的事件→update 转换(`streamSessionEventUpdate`)中的回归。与已提交的 `stdout.golden.jsonl` 比对。 +2. **重新持久化的会话 JSONL**,归一化后与 `session.jsonl` 比对。同一份 fixture 既是回放源也是期望日志。提示词文本被擦除;每个 header 类别一个场景固定可读的 prompt 和工具内容,见 [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)。覆盖场景的模型行为完全来自其伴随记录。 + +两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的循环、工具和边界结构。 + +归一化替换会话 ID、cwd、protocol-id、时间戳、路径和进程易变值,同时保留确定性序列号。场景将真实 bash 使用限制在稳定命令上。stdout golden 保持协议格式(wire format)的 JSONL,每行原始数据必须能解析为 JSON。Vitest 只更新 stdout golden;归一化后的会话相等性检查从不覆写回放 fixture。 + +### 隔离:当前靠归一化,后续可沙箱 + +工具确定性来自临时 cwd、擦除的环境变量、全新的非登录 shell、受限命令和归一化。它不声称具备 OS 级隔离。如果需要更强的层级,沙箱执行器可以通过既有的[能力 seam](../architecture/2026-06-13-capability-seams.md) 替换本地后端。 + +### 回放插件是独立的包 + +`@deepseek-ai/dsh-llm-replay` 是一个支撑包(package),而非示例本地的胶水代码。它通过用从 JSONL 重建的流短路 `llm/stream` 来替换真实适配器,其包级放置使回放逻辑处于正常覆盖率门禁之下。 + +### 两个子命令,回放在默认门禁中 + +`pnpm run test:snapshot` 无密钥回放已提交的 fixture;`test:snapshot:record` 使用真实 API 并重写采集到的会话日志和 stdout golden。fixture 缺失时大声失败。每个场景携带 `input.json`、`stdout.golden.jsonl` 和 `session.jsonl`;无模型场景使用仅含 header 的日志。`replay.override.json` 仅在标记为 `overridden` 的场景中必需,因为它的存在会替换推导出的回放。fixture 守卫拒绝缺失、不匹配和遗留的文件。两个命令都接受场景过滤器。 + +## 曾考虑的替代方案 + +- **手写的模型 chunk `llm.json`**:早期草案;复用真实会话日志使 fixture 成为系统的真实产物而非手工构建的 mock,并兼作行为 golden。 +- **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。适配器相关、与流式 SSE 配合笨拙,且层级低于被测对象。 +- **从 `turn/end {kind:'error'|'aborted'}` 合成 throw/cancel 条目**:否决。这会将 `llm-replay` 耦合到循环内部的 turn 关闭语义,且 `turn/end` reason 是有损的(无法区分抛出的 401 和 finish-error);显式的 `replay.override.json` 伴随记录是更干净的 seam。 + +## 后果 + +新层级为每个场景添加经评审的 input、session、stdout、可选 override 和可选 workspace fixture。workspace 种子在录制和回放时都被复制到临时 cwd。作为回报,该层级通过真实的 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、input、workspace、归一化和回放 harness 可以支持 ACP 以外的示例。 + +本 RFC 与[拟议的确定性 RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) 相关但不取代它:该提案的「通用回放 fixture」在每次测试后重新推导会话*消息历史*(一个内部一致性不变式),而快照测试固定的是*外部协议输出*。二者互补:一个守护事件溯源不变式,另一个守护面向编辑器的契约。 diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml new file mode 100644 index 0000000000..fadd3e4a52 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-19-real-api-e2e-ci.md: 3b5995a3e060ef9b4b1639b5c7fb17c819e73150 +2026-06-19-real-api-e2e-ci.zh.md: 4d4b38cd5989426482b773da79b3a44cec90f747 diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index ea2eb6964a..3b5995a3e0 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -1,5 +1,7 @@ # RFC: Real-API e2e in CI against the external DeepSeek API +English | [中文](2026-06-19-real-api-e2e-ci.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md new file mode 100644 index 0000000000..4d4b38cd59 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -0,0 +1,100 @@ +# RFC:在 CI 中对外部 DeepSeek API 运行真实 API e2e 测试 + +Status: implemented + +[English](2026-06-19-real-api-e2e-ci.md) | 中文 + +## 问题 + +按照既定策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../testing.md) 论证了无密钥测试套件只能验证管道连通性而非产品行为,[ACP inject 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)是现成的证据——178 个无密钥测试全绿,而真实编辑器会话一启动就崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)正是为了弥合这一缺口:它驱动 agent 对接线上 DeepSeek API——真实模型调用、真实 bash 工具、多轮对话、恢复、ACP-over-stdio。 + +默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意不携带密钥:它不含 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此把它加到 ci.yml 只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。 + +本 RFC 记录的决策是:新增一个**第二个、消费 secret 的工作流**来在 CI 中运行真实 API 套件。同时,由于这是向一个未来可能公开的仓库引入首个 CI secret,属于安全/隔离决策,本文一并记录其依赖的威胁模型以及仓库公开后会发生什么变化。 + +## 决策 + +新增专用工作流 [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml),与 ci.yml 分离。它仅在受信事件上使用仓库 secret 对外部 API 运行 `pnpm run test:e2e`,并设有预检步骤:secret 缺失时以显式失败替代假绿。无密钥工作流保持独立,使可 fork 的质量门禁与消费 secret 的真实 API 门禁各自拥有不同的触发和凭证策略。 + +### 独立工作流,而非 ci.yml 中的一个 job + +ci.yml 的价值在于它无密钥、可 fork、始终绿色:任何贡献者(包括外部 fork)都能获得完整的无密钥信号,secret 不在其爆炸半径内。在那里添加消费 secret 的 job 会将这个始终绿色的门禁耦合到凭证可用性和不同的触发策略上。将携带 secret 的工作放在独立文件中,隔离了 secret、触发和并发策略,并为 fork 保留了 ci.yml 的特性。不同的生命周期 → 不同的文件。 + +### 成本不是约束,可靠性才是 + +内部推理成本不是限制因素,因此工作流以覆盖率和信号为优化目标。它在多种触发条件和每个受信 PR 上运行所有匹配的 `*.e2e.ts` 文件,落实 [docs/testing.md](../../../testing.md) 的 with-key 策略。 + +### 触发条件:仅受信事件 + +`workflow_dispatch` + `push` 到 `main`/`master` + 每日定时 `schedule`(`17 0 * * *`,即北京时间 08:17)+ `pull_request`。push 提供合并后信号;schedule 捕获外部 API 漂移;dispatch 是手动逃生口;受信 pull request 获得合并前门禁。该合并前信号有意接受 § 安全性 中描述的更大密钥暴露面。 + +### 不受信 PR 的门禁 + +GitHub 对两类 PR 隐藏仓库 secret:来自 **fork** 的 PR,以及 **Dependabot** PR(同仓库分支,因此 `head.repo.fork == false`,但 secret 仍被隐藏)。job 级 `if:` 对两者都跳过整个 job: + +``` +github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]') +``` + +Dependabot 子句基于 PR **作者**(`pull_request.user.login`)而非 `github.actor`(运行触发者):维护者重新打开或重跑 Dependabot PR 时,`github.actor` 会变成人类,但 PR 仍然无密钥;基于作者的判断在这种情况下依然正确。被 **job 级** `if:` 跳过的 job 报告为*成功*检查(不同于工作流/触发级跳过,后者保持 pending),因此如果需要,可以安全地将此工作流标记为 required status check——fork/Dependabot PR 的跳过但绿色的检查不会阻塞合并。 + +该门禁是一个*干净跳过的便利措施*,而非 secret 的安全边界(见 § 安全性——边界是 GitHub 自身在 `pull_request` 下对 fork 的 secret 隐藏机制)。没有这个门禁,fork 仍然无法读取密钥;它们只会遇到一个令人困惑的预检硬失败并浪费计算资源。 + +### 预检:大声失败,绝不假绿 + +由于 job 仅在 secret 预期存在的受信事件上运行,预检是无条件的存在性检查:密钥为空 → `exit 1` 并附带 `::error::` 注解指明需要配置的 secret 名称。这是让自跳过套件可以安全用作门禁的关键。没有它,被删除/重命名/配置错误的 secret 会让 `test:e2e` 跳过所有真实套件并报告全绿——整个安全网的静默退化。这个守卫将「secret 缺失」从不可见的假通过变为可见的失败。(其正确性已在实际中验证:secret 存在之前的运行恰好在此步骤失败。) + +### Secret 映射与卫生 + +仓库 secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;它被映射到适配器和测试读取的 `DEEPSEEK_API_KEY` 环境变量(`process.env.DEEPSEEK_API_KEY`)。独立的 secret 名称记录了意图(这是*外部*公开 API 密钥,不是内部端点密钥),并允许内部端点密钥日后无冲突地共存。以下卫生选择均为防御性设计: + +- **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在预检和 e2e 步骤的 `env:` 中设置,绝不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 +- **`permissions: contents: read`。** 该 job 仅读取仓库以运行测试;不需要写权限(不写 PR 评论、不写 status),因此 `GITHUB_TOKEN` 降至最小权限。 +- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) 中的 `PUBLIC_BASE_URL`),但显式固定具有自文档化和密封性——一个意外的仓库根目录 `.env`(`vitest.e2e.config.ts` 如果存在会加载它)无法静默地将运行重定向到其他端点。 +- **不回显 secret。** 预检仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 + +### 范围与运行时形态 + +该 job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界可配置的 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和定时运行完整执行以提供合并后信号。 + +## 安全性 + +仓库的首个 CI secret 需要一份记录在案的威胁模型,因为同仓库 PR、fork PR 和 Dependabot PR 之间的访问权限不同,且仓库公开后会发生变化。 + +### 今天谁能触及 secret(私有仓库) + +- **无写权限(fork PR):不能。** 两个独立事实阻止了它。第一,工作流使用 `pull_request` 而**非** `pull_request_target`——GitHub 不会将仓库 secret 传递给 fork PR 的 `pull_request` 运行,因此 `secrets.DEEPSEEK_API_KEY_EXTERNAL` 在 fork runner 上解析为空。第二,`if:` 门禁完全跳过 fork PR。secret 隐藏机制是真正的边界;门禁是纵深防御和用户体验。 +- **写(push)权限:能。** 同仓库分支 PR 会收到 secret,因此有写权限的作者可以修改测试代码(或安装生命周期脚本,或其分支上的工作流 YAML)来窃取密钥。这是 **GitHub Actions 固有的,并非本文引入的**:任何对任何仓库有 push 权限的人都可以通过编写工作流来窃取该仓库的任何 Actions secret。写权限 ⇒ secret 访问权,始终如此。缓解措施在于谁被授予写权限以及分支保护,而非本文件。 + +因此「任何能开 PR 的人都能窃取它」是错误的:只有写权限集合内的人能,而该集合本来就能窃取仓库持有的任何 secret。 + +### `pull_request` 触发器增加的残余暴露面 + +由于启用了 PR 运行,密钥会在合并前被交给**写权限作者 PR 分支上的代码**。这比 `push` + `schedule` + `workflow_dispatch` 的暴露面更大,为了在受信写权限集合内获得合并前信号而被接受。如果这一权衡发生变化,可以去掉 `pull_request` 触发器,同时保留合并后、每夜和按需覆盖。 + +### 仓库公开后会发生什么变化 + +**通过本工作流**,secret 对公众仍然受保护:`pull_request` 在公开仓库上行为一致——fork PR(现在任何人都能开)仍然收不到 secret,且在公开仓库上 GitHub 额外要求维护者批准 fork PR 运行,即使批准后运行也不会获得 secret(批准运行不等于交出密钥)。写权限集合不因可见性改变,因此内部人员的现实也不变。 + +变差的是*周边*模型,以下是翻转可见性之前需要处理的事项: + +- **日志变为全球可读。** 今天泄露给组织成员的粗心 secret 回显,在公开后会泄露给整个互联网并在几分钟内被爬取。secret 处理纪律(不回显值/长度——已完成)的重要性大幅提升。 +- **`pull_request_target` 陷阱变为灾难性的。** 如果有人为了「修复」PR 运行而将触发器切换为 `pull_request_target`,工作流将在 base 仓库上下文中运行不受信的 fork 代码**并携带** secret——完整的密钥泄露向量。这在私有仓库上尚可容忍,在公开仓库上则是灾难。e2e.yml 中触发器上的 `SECURITY —` 注释禁止此更改并指向本文。 +- **翻转时轮换密钥。** 该密钥曾存在于私有仓库的 CI 中;将公开视为「假设已暴露」,在那一刻轮换 `DEEPSEEK_API_KEY_EXTERNAL`。 +- **将 secret 置于控制之下。** 确认 Settings → Actions → *"Send secrets to workflows from fork pull requests"* 保持**关闭**(这是唯一能真正打破 fork 边界的设置),并考虑将密钥移入带有 required reviewers 的 GitHub **Environment**,使即使已合并的代码也只在受控条件下使用它,且轮换有一个统一的归属。 + +以上均不需要修改工作流即可公开仓库;它们是运维步骤加上已添加的 `pull_request_target` 守卫注释。 + +## 曾考虑的替代方案 + +- **在 ci.yml 中添加消费 secret 的 job**:否决。它会将无密钥、可 fork、始终绿色的门禁耦合到凭证可用性和不同的触发/并发策略上;不同的生命周期,不同的文件。 +- **省略 `pull_request` 触发器**(更小的密钥暴露面):为了合并前信号而否决;安全性一节承载了被接受的暴露分析。 + +## 后果 + +新增一个 CI 工作流和仓库首个需要维护的 secret。真实 API 套件现在成为合并门禁(受信 PR 上的合并前门禁、main 分支上的合并后门禁)并每夜运行,因此 agent 与外部 API 交互中的真实故障会在 CI 中浮现,而非仅在开发者的本地运行中出现——代价是每个受信 PR 和合并都会产生真实(但内部免费)的 API 调用。预检使 secret 配置错误变为自我通告而非静默禁用安全网。 + +本设计携带一个记录在案的约束面:`pull_request` 触发器的密钥暴露权衡(去掉它以加固)、`if:` 门禁对基于作者的 Dependabot 判断的依赖,以及对 `pull_request_target` 的硬性禁止。上述公开清单是运维伴侣——本 RFC 是未来维护者在更改触发器集合或翻转仓库可见性之前应重新阅读的地方,而非从头重新推导 fork/secret 模型。 + +定时触发器在仓库不活跃 60 天后会自动禁用(GitHub 行为);push/PR/dispatch 是后备,活跃的 monorepo 不会触及此限制。假设 runner 可出站访问 `https://api.deepseek.com`——GitHub 托管的 `ubuntu-latest` 具备此条件;出站受限的自托管 runner 需要在依赖每夜运行之前确认连通性。 diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml new file mode 100644 index 0000000000..592c10b7e0 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-remove-redundant-snapshot-log-goldens.md: badd32d4479ac6d44bb7be3cd262cba57b1b3a38 +2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: 35e4a698cbd19bcceb97df714d2b6bfb371c155a diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 45348cfd2e..badd32d447 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -1,5 +1,7 @@ # RFC: Use `session.jsonl` as the only snapshot session-log artifact +English | [中文](2026-06-20-remove-redundant-snapshot-log-goldens.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md new file mode 100644 index 0000000000..35e4a698cb --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md @@ -0,0 +1,37 @@ +# RFC:使用 `session.jsonl` 作为唯一的快照会话日志产物 + +Status: implemented + +[English](2026-06-20-remove-redundant-snapshot-log-goldens.md) | 中文 + +## 问题 + +模型驱动的 ACP 快照场景同时包含 `session.jsonl` 和 `session.golden.jsonl`。对于普通录制场景,`session.jsonl` 是从真实运行中采集的回放 fixture(测试前置数据),回放测试将新持久化的日志归一化后与 `session.golden.jsonl` 比较。在当前 fixture 中,普通录制场景的归一化录制日志与归一化 golden 完全相同。 + +手工编写的覆盖场景(`error-finish`、`cancel`)目前使用 `replay.override.json` 驱动模型行为,并保留 `session.jsonl` 作为最小占位 fixture,而 `session.golden.jsonl` 存放预期的持久化日志。覆盖文件是一个 `ReplayEntry` 对象的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }`、`{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }` 或 `{ "kind": "hang" }`。这种拆分同样没有必要:当覆盖 sidecar 存在时,`llm-replay` 会替换派生的脚本,不需要从 `session.jsonl` 获取模型分片,因此 `session.jsonl` 仍然可以充当该场景的预期会话日志产物。 + +## 决策 + +彻底移除 `session.golden.jsonl` 概念。每个场景最多只有一个提交的会话日志产物 `session.jsonl`: + +- 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行的归一化持久化日志与归一化后的 `session.jsonl` 比较。 +- 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时回放适配器会忽略 fixture 中的模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。 +- 对于无模型场景,`session.jsonl` 可以保留为启动 `llm-replay` 所需的最小 fixture;除非该场景创建了持久化会话,否则无需进行会话日志比较。 + +stdout golden 保持不变;它们是面向编辑器的投影,与会话 fixture 并不冗余。 + +## 曾考虑的替代方案 + +**基于共享(回放运行)上下文对两侧进行归一化**:否决。`normalizeSessionLog` 通过精确字符串匹配擦除 cwd,因此 fixture 中录制的 cwd 不会被擦除,每次比较都会失败。两侧各自基于自身 header 派生的上下文进行归一化——下方的实现说明描述了具体机制。 + +## 验证 + +`session.golden.jsonl` 不再出现在快照 harness、fixture、遗留文件守卫或文档中的任何位置;快照测试对每个模型场景都从 `session.jsonl` 派生预期会话日志;手工编写的 sidecar 场景将其预期产出的日志提交为 `session.jsonl`,并以 `replay.override.json` 作为模型行为覆盖;遗留 fixture 守卫知道每种场景类型需要哪些文件。[ACP 快照测试 RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) 描述了精简后的 fixture 集合。 + +## 后果 + +评审人失去了一个让预期持久化日志在视觉上与回放 fixture 分离的产物名称。stdout golden 仍然保护编辑器 transcript(文本记录),将回放输出与 `session.jsonl` 比较则在不重复文件的前提下保留了 agent loop(智能体循环)/持久化的回归检查。 + +## 实现说明 + +两侧各自基于自身 header 值进行归一化,因为录制与回放具有不同的 id、路径和时间戳。`fixtureContext()` 从 fixture 的 header 派生 fixture 上下文,使已归一化的 fixture 具有幂等性。会话日志使用普通相等比较而非文件快照更新,因此比较过程永远不会改写 fixture。 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml new file mode 100644 index 0000000000..73c068be2c --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-22-fork-child-replay-seed-boundary.md: a0bf064508107a23147df1a6c824c53a3906c43e +2026-06-22-fork-child-replay-seed-boundary.zh.md: 7ee6c3d373ff5e598efa82d5c8fbad6b8e162aeb diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index 14db415b1b..a0bf064508 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -1,5 +1,7 @@ # RFC: Persist the seed boundary so fork-child replay routes correctly +English | [中文](2026-06-22-fork-child-replay-seed-boundary.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md new file mode 100644 index 0000000000..7ee6c3d373 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -0,0 +1,49 @@ +# RFC:持久化 seed 边界以确保 fork 子会话回放路由正确 + +Status: implemented + +[English](2026-06-22-fork-child-replay-seed-boundary.md) | 中文 + +## 问题 + +[逐会话快照回放 RFC](2026-06-22-subagent-snapshot-replay.md) 让快照层表达了嵌套 agent 的结构:一个父会话加上每个进程内 subagent 各一份录制日志,每份日志以调用方会话为键独立回放为自己的脚本。该 RFC 在 §Scope 末尾提到 fork 快照是「一个简单的后续补充,不是键控方案的缺口」。这个说法对 fork 子会话而言是错的——问题不在键控,而在*脚本推导*。 + +subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从录制的会话日志推导而来:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自己的模型调用。 + +**fork** 子会话不同。fork 后端用*父会话日志中一段平衡的已完成轮次前缀*([`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess))来初始化子会话,而这段 seed 会成为子会话持久化的 `log`(`Session` 构造函数将 seed 复制到 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自己的轮次。 + +如果从 fork 子会话的完整日志推导脚本,就会把**父会话**录制的响应当作**子会话**的模型调用来回放:活跃的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段 chunk 序列而非自己的。目前录制的场景全部是 spawn,所以这个问题从未触发——但 fork 快照会静默地路由错误,而这恰恰是快照层存在的意义所要捕获的那类 bug。 + +## 决策 + +记录会话**继承**前缀的结束位置,将其持久化,并让回放 harness 仅从子会话**自身**的事件推导脚本。 + +### 1. 会话头部的 `seedLength` + +`SessionHeader` 新增可选字段 `seedLength: number`:表示前导多少个事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= seed 前缀长度);新建的 spawn 子会话不设置(等价于 0)。该字段通过 `CreateSessionOptions.meta`(以及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。 + +`seedLength` 是**显式**的,从不从 `seed.length` 推断。重建(resume/load)时用会话的完整存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——重建路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:重建时显式保留,而非重新默认为当前时间。) + +### 2. 两个持久化后端都完整往返 + +- **JSONL**:header 行上的 `seedLength` 字段(`toHeaderLine`/`fromHeaderLine`)。 +- **SQLite**:`sessions` 表上的 `seed_length` 列。 + +包含 `seed_length`、`source_event_seqs` 和 `surface_op` 的 SQLite 布局为 schema version 4。更早的 version 3 布局存在歧义,因此按预发布政策,所有非当前 `user_version` 均直接拒绝,不做迁移。 + +### 3. 回放在边界之后推导子会话脚本 + +`dsh-llm-replay` 的 `parseSessionHeader` 现在也读取 `seedLength`(缺失 ⇒ 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(seedLength)` 推导子会话的条目——即边界处及之后的事件,也就是子会话自己的模型调用。对 spawn 子会话而言 `seedLength` 为 0,这是一个空操作,因此 spawn 场景逐字节不变。 + +这关闭了路由正确性的缺口,两个录制的 fork 场景对其进行了端到端验证——见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)。 + +## 曾考虑的替代方案 + +- **在 `llm-replay` 中启发式推导边界**(seed 前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「包边界处显式优于隐式」规则跨持久化边界的应用——子会话 fixture 的读取方永远不需要重建继承在哪里结束。 +- **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」策略)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(一小组值得区分的修订),与事件词汇的 `version` 不同。新增列正是它所版本化的那种破坏性表结构变更,因此递增。 + +## 后果 + +- 在 core 与两个后端之间新增一个持久化的 header 字段;核心数据结构目录(`persistence.md`)在同一个变更中更新(其 `SessionHeader` / `CreateSessionOptions` 的 `type-equiv` 块)。 +- 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。 +- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自己的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其 seed 前缀包含父会话的 chunk——推导出的子会话脚本必须排除它,不做 slice 时该用例为红),以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml new file mode 100644 index 0000000000..7312eb5cab --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-22-fork-snapshot-scenarios.md: baca94d6a1071ec38ee20ca841fc3472a870b1a2 +2026-06-22-fork-snapshot-scenarios.zh.md: 227d54cc2bb2e66a391dddd29a7f2593cb02f7e2 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md index b2c39047b9..baca94d6a1 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -1,5 +1,7 @@ # RFC: Record fork and mixed spawn+fork snapshot scenarios +English | [中文](2026-06-22-fork-snapshot-scenarios.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md new file mode 100644 index 0000000000..227d54cc2b --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md @@ -0,0 +1,31 @@ +# RFC:记录 fork 与混合 spawn+fork 快照场景 + +[English](2026-06-22-fork-snapshot-scenarios.md) | 中文 + +Status: implemented + +## 问题 + +[seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) 让 fork 子会话的回放路由正确工作了:`dsh-llm-replay` 从子会话持久化的 `seedLength` 边界处或之后的事件推导出子会话的脚本,因此 fork 子会话继承的父会话前缀不会被当作子会话自身的模型调用来回放。但该 RFC 交付时**没有录制 fork 场景**:切片逻辑仅由 `llm-replay` 的单元测试(一个合成的子会话 fixture(测试前置数据))和一个持久化往返测试覆盖。全 transcript(文本记录)快照层——那个启动真实 `acp-agent` 并回放端到端嵌套 transcript 的网——只有 spawn 子会话(`subagent-spawn`、`subagent-multi`)。一个让单元测试保持绿色的 fork 路由回归,仍然会逃过专为捕获 transcript 回归而建的那一层。 + +表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都通过 `cordis.yml` / `cordis.snapshot.yml` 接入为两个面向模型的工具(`subagent` → spawn、`subagent_fork` → fork),harness 收集每个子会话的日志,回放按 `seedLength` 为键转发每个子会话的 fixture。缺少的只是一个*录制好的场景*来驱动 fork 子会话走完这条路径。 + +## 决策 + +对真实 API 录制两个场景,均在默认门禁中以 keyless 方式回放: + +- **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此能从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的录制而非手工合成。 +- **`subagent-mixed`**:父会话完成一个轮次,然后在同一个 transcript 中分别通过 `subagent`(全新的 spawn 子会话,`seedLength` 为 0)和 `subagent_fork`(fork 子会话,非零 `seedLength`)各委派一次。这是 seed-boundary 和 per-session-replay 两份 RFC 都提到的「未来补充」的混合 spawn+fork 场景:一个 transcript 同时覆盖两种传输方式和切片的两个分支(`seedLength` 0 = 无操作,`seedLength > 0` = 裁掉继承的前缀),两个子会话按 `createdAt` 排序为先 spawn 后 fork。 + +### 为什么需要一个已完成的第一轮次 + +fork 后端用父会话的**已完成轮次的平衡前缀**([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来填充子会话种子。如果父会话在第一个轮次就 fork,则没有已完成的轮次可继承,种子为空(≡ 全新 spawn,`seedLength` 为 0),这**不会**覆盖切片逻辑。因此两个场景都使用两条提示词输入:第一条提示词完成一个轮次(建立一个 codeword 供子会话稍后回忆),第二条委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带结果;真正承载验证的产物是子会话 fixture 中录制的 `seedLength`,回放切片消费的正是它。 + +## 后果 + +- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话录制的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 +- `subagent-mixed` 是第一个在同一个 transcript 中驱动两个*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。 +- 进程外(ACP)subagent 回放是另一种形态(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。 +- 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在没有 key 时与所有录制场景一样自动跳过。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml new file mode 100644 index 0000000000..9ce1aefb81 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-22-subagent-snapshot-replay.md: fbb2e5b93cced118a24f5560f229e2dc341bf3b2 +2026-06-22-subagent-snapshot-replay.zh.md: fd4ba0c109d77fdf9b64e25de74d3da577ce5a9b diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index 8135f6afd1..fbb2e5b93c 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -1,5 +1,7 @@ # RFC: Per-session snapshot replay for nested agents +English | [中文](2026-06-22-subagent-snapshot-replay.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md new file mode 100644 index 0000000000..fd4ba0c109 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -0,0 +1,58 @@ +# RFC:嵌套 agent 的逐会话快照回放 + +Status: implemented + +[English](2026-06-22-subagent-snapshot-replay.md) | 中文 + +## 问题 + +快照测试层(`pnpm run test:snapshot`)启动真实的 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放录制的会话,并将归一化后的 stdout transcript(文本记录)与重新持久化的会话日志同提交的 golden 文件做 diff。这是唯一一个端到端验证完整编辑器侧 transcript 的测试层。 + +它最初为**单会话单进程**而建,这一假设硬编码在两处: + +- **`dsh-llm-replay` 没有任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent 和进程内 subagent 同时在一个 context 上流式输出时,调用交错,单一游标会把子 agent 的脚本交给父 agent(反之亦然)。 +- **harness 只收割一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的**第一个** `.jsonl`。subagent 作为同一 cwd bucket 中的第二个 `Session` 运行、拥有自己的日志,因此子 agent 的 transcript 被静默丢弃。 + +这正是 [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 中记录的 `TODO(subagent-snapshots)` 延期项:进程内后端(PR2)已有单元测试和 e2e 覆盖,但全 transcript 快照层在本基础设施落地之前无法表达嵌套 agent 的形态。本 RFC 即为该堆叠后续。 + +## 决策 + +回放按**调用方会话**键控,harness 收割**所有**会话日志。 + +### 1. 调用方会话 id 随模型请求传递 + +`GenerateOptions` 新增可选字段 `sessionId`,在请求组装时从 `agent.session.id` 打入。适配器忽略它;`llm/stream` 监听器用它按发起方会话路由。其类型为 `Branded<'SessionId'>`(来自 `dsh-brand`)而非 `dsh-session` 的 `SessionId`,因为后者所在包导入了 `dsh-llm` 的 `Message`,反向导入会形成循环。两个类型等价,会话 id 赋值无需强制转换。将 brand 移入专用 ids 包属于独立工作,因为它会触及所有 id 导入。 + +### 2. 回放按首次调用顺序将活跃会话绑定到录制脚本 + +嵌套场景录制不止一份日志:父会话(`session.jsonl`)加每个 subagent 子会话各一份(`session.1.jsonl`、……)。`dsh-llm-replay` 全部加载,为每个录制会话推导一份脚本,并按 header 中的 `createdAt` 排序(父会话先于子会话创建)。 + +活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id,因此活跃会话无法通过 id 相等绑定脚本。取而代之的是**首次调用顺序**绑定:第一个发起模型调用的活跃会话认领排序第一的脚本(即父会话——`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。之后每个会话独立推进自己的游标。 + +这按**谁在调用**键控,而非按全局调用顺序——因此即使 subagent 将来并发运行或在后台运行也保持正确(全局游标会导致交错)。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径的行为与旧版逐字节一致。活跃会话数多于录制脚本数是一个 fail-loud 错误(出现了未录制的 subagent),绝不会静默误路由。 + +子 fixture 按 `createdAt` 排序,在兄弟会话严格顺序执行时与调用顺序一致。id 平局打破只是让退化碰撞确定化。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。 + +## 曾考虑的替代方案 + +曾考虑并否决的方案是将父子日志**按调用顺序合并**为一份全局脚本(仅在进程内 subagent 严格嵌套执行——父 agent 阻塞等待子 agent——时才正确)。对当前的同步切面更简单,但把「父阻塞于子」这一不变式烤死了;未来的后台/并发 subagent 会打破它,而逐会话键控不会。 + +### 3. harness 收割所有日志,主会话优先 + +`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`(JSONL 后端将父会话与同 cwd 的子会话放在同一 bucket),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回 fixture(`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收割的日志与对应 fixture 做 diff。归一化器已接受复数会话 id 并折叠任何游离 UUID,因此无需修改归一化器。 + +### 4. 场景 + +新增两个嵌套场景,均对真实 API 录制: + +- **`subagent-spawn`**:父 agent 通过 `subagent` 工具将一个子任务委派给一个新 spawn 子会话(2 个会话)。 +- **`subagent-multi`**:父 agent 委派两个子任务,各自交给独立的 spawn 子会话(3 个会话),以三份并行脚本和同一父会话下两个子会话的 `createdAt` 排序来压测逐会话键控。 + +两者均在默认门禁中以 keyless 方式回放。 + +## 后果 + +- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent transcript 现在是快照的一等形态。 +- `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外也有用(遥测、请求路由)。 +- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子会话都是 spawn(全新)。键控按会话路由而非按后端路由,因此对 fork 也已正确。但脚本*推导*并非如此:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,从整份日志推导脚本会把父会话的响应当作子会话的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。 +- 进程外(ACP)subagent 是完全不同的回放形态(每个子 agent 是独立进程、有自己的 replay),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml new file mode 100644 index 0000000000..10cfb7320c --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-hook-snapshot-matrix.md: 8505e82fb681975c7506102a3eb858a29ccc11c8 +2026-07-04-hook-snapshot-matrix.zh.md: 6bd4b65b6ba2e16f8433fb0e67ae7ca4eb6a470e diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index edfcc18585..8505e82fb6 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -1,5 +1,7 @@ # RFC: Hook snapshot matrix — end-to-end goldens for both bridges +English | [中文](2026-07-04-hook-snapshot-matrix.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md new file mode 100644 index 0000000000..6bd4b65b6b --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md @@ -0,0 +1,50 @@ +# RFC:钩子快照矩阵——覆盖两种桥接的端到端金标测试 + +Status: implemented + +[English](2026-07-04-hook-snapshot-matrix.md) | 中文 + +## 问题 + +钩子桥接——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code 钩子点)与 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 钩子点)——将外部钩子命令映射到 harness 的拦截 seam 上。它们拥有深度的单元测试与覆盖率规格覆盖(每个决策分支、每种 payload 方言,均对 mock seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但全 transcript(文本记录)快照层——那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将归一化的 ACP stdout 与重新持久化的日志对比已提交金标的网——只覆盖了**一个**钩子:Claude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。 + +这正是 mock 单元测试在结构上无法替代的层级:它让真实的桥接翻译真实钩子进程的结果,送入真实的 seam 决策,再由真实的 agent loop(智能体循环)做出反应,渲染结果与编辑器所见完全一致。一个桥接翻译或循环结构的回归,即使让所有单元测试保持绿色,也会在除那一个钩子点之外的所有点上逃逸——而对于 Codex 桥接,ACP 示例甚至没有加载它,因此没有任何 Codex 钩子能端到端触发。 + +## 决策 + +实现由两个耦合部分组成: + +### 1. ACP 示例同时加载两种钩子桥接 + +`examples/acp-agent/cordis.yml` 与 `cordis.snapshot.yml` 现在在 `dsh-hooks-claude` 之外同时加载 `dsh-hooks-codex`,各自指向自己的配置文件(Claude 用 `./hooks.json`,Codex 用 `./codex-hooks.json`——两种方言无法共用一个文件)。这是一个真正的产品表面变更,而非仅测试用的接线:交付的 ACP 服务器(以及 `demo:acp` 入口)现在同时携带两种桥接。 + +这是安全的,因为配置文件不存在时桥接是**静默空操作**:`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不挂载 stdout logger,因此该警告不会到达 ACP JSON-RPC 通道。只需要 Claude 钩子的场景(或真实项目)只提供 `hooks.json`;Codex 桥接找不到 `codex-hooks.json` 便自行消失。这已通过实验验证:两种桥接同时加载时,所有既有快照(均未提供 `codex-hooks.json`)逐字节一致。 + +同时加载是让快照层能够在产品交付的同一个真实应用上对每种方言进行测试的最低要求。录制(启动 `cordis.yml`)天然加载两者,回放以同样方式继承:`cordis.snapshot.yml` 是 `cordis.yml` 的 include-overlay,仅替换 llm 条目(见 [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)),因此添加到运行时配置树的桥接无需第二次编辑即出现在回放树中。 + +### 2. 每个钩子点 × 其标志性结果各一个快照场景,覆盖两种方言 + +`examples/acp-agent/tests/snapshots/` 下共 13 个场景,命名为 `hook-<dialect>-<point>-<outcome>`: + +- **手工编写、无模型轮次**(无密钥、无 sidecar——派生的回放脚本为空;比对的是携带 `hook/*` 事件的 `rejected` 轮次):`hook-cc-promptsubmit-block`、`hook-codex-promptsubmit-block`。 +- **对真实 API 录制、录制期间钩子活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`(allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`(deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`(block 并附反馈)、`hook-{cc,codex}-posttool-context`(accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞式 Stop 钩子通过 steering(中途引导)强制多走一步)。 + +每个钩子命令只输出**固定字面字符串**(无时间戳/pid/`$RANDOM`/cwd 回显);快照归一化器擦除 `hook/result` 携带的唯一易变字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是桥接的一个 `TODO`,因此无条件的 Stop 钩子会对每一步都 force-continue。 + +### 三个钩子点被有意排除在快照之外 + +在构建矩阵过程中发现,记录在此是因为这是一个决策而非疏漏: + +- **`SessionStart` 与 `SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,**没有轮次绑定**。产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的金标甚至在自身回放时都无法复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在桥接的单元覆盖中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——这些点就可以纳入快照。) +- **`SubagentStop`** 是纯观察:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript **什么都不写**,因此金标会与无钩子运行逐字节一致,永远无法被证明失败——一道咬不到人的守卫。它留在单元覆盖中(`bridge.spec.ts` 已断言该纯观察调用)。 + +因此该矩阵覆盖了所有具有**确定性、可观测 transcript 足迹**的钩子点,涵盖两种方言。 + +## 后果 + +- 每个具有可观测 transcript 的桥接 seam 映射现在都在全 transcript 层、在真实应用中、为两种方言设有守卫——包括此前完全没有端到端覆盖的 Codex 桥接。录制的金标捕获了模型对 denied/blocked/force-continued 轮次的真实反应,这是手工编写的 transcript 只能猜测的。 +- block 场景无需密钥(无模型轮次);其余场景从录制的 fixture(测试前置数据)无密钥回放。`pnpm run test:snapshot:record` 从真实 API 重新生成录制的 fixture,无密钥时像所有录制场景一样自动跳过。 +- prove-red 纪律成立:篡改钩子配置的输出(例如修改 deny 原因)会使其场景在回放时变红——钩子进程在回放期间**真实运行**(只有模型被回放),因此金标守卫的是实际的 hook→seam→loop 路径,而非它的 mock。 +- `acp-agent` 演示现在加载了一个通常会空操作的 Codex 桥接(典型项目中没有 `codex-hooks.json`),这正是预期的 fail-soft 行为,而非代价。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml new file mode 100644 index 0000000000..642ba7f9ad --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-single-source-acp-replay-config.md: 922bdcced50f8e289449e05b51774f202228b0f8 +2026-07-04-single-source-acp-replay-config.zh.md: d27ea0d3b7227f0fb5f349478591962dd5fe8030 diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md index 70730f0382..922bdcced5 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -1,5 +1,7 @@ # RFC: Single-source the acp-agent replay config +English | [中文](2026-07-04-single-source-acp-replay-config.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md new file mode 100644 index 0000000000..d27ea0d3b7 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md @@ -0,0 +1,27 @@ +# RFC:将 acp-agent 回放配置收归单一来源 + +Status: implemented + +[English](2026-07-04-single-source-acp-replay-config.md) | 中文 + +## 问题 + +`examples/acp-agent` 曾维护两份手写配置:`cordis.yml`(线上树)和一份 `cordis.snapshot.yml`,后者逐条镜像前者、仅替换 LLM 后端。去掉注释后,全部差异只是八行 `llm-deepseek` 段落换成两行 `llm-replay` 段落。每次应用形态变更都要改两遍,且没有门禁保证对称性:如果两份副本漂移,快照层会静默地测试一个与实际交付不同的应用——正是快照层本身要消除的[「单元全绿、产品却坏」类缺口](../../../postmortem/0001-acp-default-export-drops-inject.md),在更高一层被重新引入,唯一的防线是评审者的警觉。 + +## 决策 + +`cordis.snapshot.yml` include 线上配置,按 id 和 name 禁用指定的 DeepSeek 适配器,并插入回放适配器。因此除此之外的所有条目均来自交付树。回放时选择 overlay;录制仍然启动 `cordis.yml`,加载守卫允许被有意禁用的条目。 + +overlay 依赖的一个 vendor 插件事实(有意为之):include 在加载文件时应用 `patches`——其 `refresh()`/`internal/update` 路径重读时不重新打补丁——这恰好满足一次性回放启动的需要(回放应用不加载 `hmr`,也没有东西在运行中改写配置)。快照套件即为证明:所有场景在 overlay 上原样通过,包括逐字节一致的 golden 文件。 + +## 曾考虑的替代方案 + +### 为什么不选这些方案? + +保留完整的双份配置并加一道对称性校验门禁是记录在案的兜底方案——它能消除静默漂移这一类问题,但仍保留一份 125 行的近似副本,其全部内容只是一个条目的差异,且随应用每增加一个插件而增长。在 bin 侧做替换(解析配置、替换条目、删除文件)会把 YAML 手术放进发布产物,并将回放差异移出视野;overlay 方案让差异保持声明式、可读、且紧邻基础配置——这正是双份配置的支持者真正想要的教学价值。 + +## 后果 + +- 向 `cordis.yml` 添加的插件无需第二次编辑即进入回放树;漂移类问题从结构上消除,而非仅靠门禁拦截。 +- overlay 依赖条目携带稳定的 `id:`。禁用补丁上的 `name` 断言防止误定位(id 被复用时补丁跳过而非禁用错误的插件)。id **重命名**会使补丁退化为跳过,其警告需要一个回放应用有意不具备的 logger——可观察的结果是一条无用的无密钥 `llm-deepseek` 条目与 `llm-replay` 并存,回放输出仍然正确(`llm-replay` 拥有流的短路权);这属于留给评审发现的配置腐烂,而非错误的快照。顶层插入的条目若 id 与已有条目冲突,通过 loader 的 id map 以 last-wins 解析——当前配置无冲突,新增补丁行才是引入冲突的位置。 +- 如果未来回放树需要第二处分歧(另一个后端被替换),只需多加一行补丁,而非再 fork 一份文件。 diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml new file mode 100644 index 0000000000..3ee0a6df6d --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-pin-request-header-content-in-one-scenario.md: 5ccaa23a268114c5ba37ec153f4960b47df13bfd +2026-07-06-pin-request-header-content-in-one-scenario.zh.md: f5ef5e056bb2c05d14ed71315f31c962237db72c diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md index 42c67320f6..5ccaa23a26 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md @@ -1,5 +1,7 @@ # RFC: Pin request-header content in one snapshot scenario +English | [中文](2026-07-06-pin-request-header-content-in-one-scenario.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md new file mode 100644 index 0000000000..f5ef5e056b --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md @@ -0,0 +1,35 @@ +# RFC:在单一快照场景中固定 request-header 内容 + +[English](2026-07-06-pin-request-header-content-in-one-scenario.md) | 中文 + +Status: implemented + +## 问题 + +ACP(Agent Client Protocol)快照测试套件需要证明每个 `request/header` 中实际发送的组合系统提示词和工具 schema 列表,但如果在每个 `session.jsonl` 中重复这些内容,一次提示词或 schema 编辑就会改写数十条巨大的单行 JSON 记录。保留一份原始 header 可以避免重复,但提示词的评审体验仍然很差:行文被 JSON 转义到一行里,与数千字符的工具 schema 混在一起。 + +## 决策 + +每个 header 组合类别恰好有一个场景被标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.golden.md` 以普通 Markdown 存放归一化后的组合提示词,`tool-schemas.golden.json` 以结构化 JSON 存放完整的初始 schema 及后续 schema 变更,`session.jsonl` 保留 config、reason 和任何模型可见的前缀,同时将 `header.system` 和 `header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其余所有 JSONL 使用相同的提示词和工具 token,并同样对 session-prefix 内容做 token 化。固定机制实现在 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) 中,其套件工厂强制每个类别只有一个 pin。 + +纯净的 `scrubSystemPrompts` 和 `scrubToolSchemas` 归一化器应用于所有存储的 session fixture(测试前置数据),独立地对初始 header 内容和 header-delta 批量内容做 token 化。`scrubRequestHeaders` 还为非固定场景对 session-prefix 内容做 token 化,同时保留结构性事实:system-delta 的位置与数量、增删改的工具名称、前缀消息数量、字段存在性、config 和 reason。record 和 refresh 的回写操作在写入 JSONL 前应用相应的 scrub,并从归一化的实时 header 和 delta 重新生成两个 sidecar 文件,因此两条路径都不会将提示词/schema 批量内容重新引入 JSONL,也不会让评审产物变陈旧。 + +守卫使这一拆分自我强制。在磁盘上:每个 `session*.jsonl` 都是提示词和 schema 两个 scrubber 的不动点;只有非固定 fixture(测试前置数据)还必须是完整 header scrub 的不动点;两个 sidecar 恰好存在于固定 fixture 旁边,采用规范的换行终止格式;每个类别有且仅有一个 pin。在运行时:由 parent、spawn 子进程、fork 子进程、初始请求或恢复产生的每个 `request/header`,在易变值归一化后必须与重建的 pin 匹配;固定运行的提示词和 schema delta 也必须与其 sidecar 匹配。如果 header 缺少字符串类型的 prompt、缺少数组类型的工具列表,或出现未声明的 `request/header-delta`,则立即报错。 + +一个 pin 覆盖整个套件,因为每个会话(parent、spawn 子进程、fork 子进程)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合在设计上变为会话相关的(例如受限的 subagent 工具集),则分化出的形状获得自己的固定场景。 + +## 曾考虑的替代方案 + +- **每次变更重新录制或手动编辑所有 fixture**:保留了精确的 header,但行为差异被重复的提示词和 schema 内容淹没。 +- **仅在比较时 scrub,fixture 保持原始状态**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时整体改写。存储 token 诚实地表明每个 JSONL 没有固定什么。 +- **全部 scrub,不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema)的唯一端到端记录。生成的工具目录只孤立地记录每个工具;只有真实 fixture 能固定组合后的完整集合。 +- **将完整的 pin 全部保留在 JSONL 中**:消除了套件级重复,但提示词和 schema 变更仍然表现为一行转义文本。Markdown 和结构化 JSON 为各自的内容提供了自然的评审格式,同时不削弱重建 header 的断言。 +- **精简会话日志本身(记录内容摘要,header 存到别处)**:违反可重建契约:产品日志必须逐比特重现每个请求(见[可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md))。header 体积是测试产物的问题,在测试归一化中解决;线上日志不受影响。 + +## 验证 + +套件针对拆分后的 pin 回放每个场景。单元覆盖率检验独立 scrubber 和完整 scrubber、两种 sidecar 格式、record/refresh 重新生成、归一化的提示词/schema 提取、不动点强制、必需文件对称性、重建 header 的一致性,以及 delta 拒绝。 + +## 后果 + +系统提示词的变更在每个受影响的组合类别中产生一个面向行的 Markdown diff;工具描述的变更在每个类别中产生一个结构化 JSON diff;普通的行为 fixture 不受影响。session fixture 以 token 显示被省略的内容,运行时一致性守卫使每个拆分 pin 对其类别中的所有会话具有权威性。每个固定场景携带两个生成的、换行规范化的 sidecar 文件。 diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml new file mode 100644 index 0000000000..d888da4d05 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-08-shared-acp-snapshot-package.md: 81714191a704af1a9ed029fb8004deac88e39427 +2026-07-08-shared-acp-snapshot-package.zh.md: 2a7c3091c731ded61bed939c8ae0a323123daac9 diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index 1fb887db52..81714191a7 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -1,5 +1,7 @@ # RFC: Extract the ACP snapshot suite into a support package +English | [中文](2026-07-08-shared-acp-snapshot-package.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md new file mode 100644 index 0000000000..2a7c3091c7 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -0,0 +1,38 @@ +# RFC:将 ACP 快照测试套件提取为支持包 + +Status: implemented + +[English](2026-07-08-shared-acp-snapshot-package.md) | 中文 + +## 问题 + +ACP 快照层([快照 RFC](2026-06-19-acp-snapshot-tests.md))由三个位于某个示例测试目录内的模块构成:`snapshot-harness.ts`(启动真实 bin 子进程、通过 ACP JSON-RPC 驱动它、收集持久化日志)、`snapshot-normalize.ts`(纯粹的 golden 归一化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(record/replay 模式、stdout-golden 与日志比对、pinned-header 一致性守卫、orphan/required-file/single-pin 元测试)。 + +第二个 ACP 示例只能复制 record、归一化与收集逻辑,而这些逻辑必须保持一致。`examples/` 下的代码还处于包(package)覆盖率门禁之外,且原有 harness 只能取消权限请求。共享包使这些机制纳入度量,并允许场景脚本化地指定审批答案。 + +## 决策 + +机制代码位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)(`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,配合自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` 覆盖层([单源 replay 配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在该边界——库接收的是已解析的 `mode`。 + +**`src/harness.ts`** 提供 `runScenario` 及其脚本/结果类型,以 agent 的 bin 路径和配置路径为参数。权限答案构成一个 FIFO 队列,按稳定的 option kind(而非随机的 option id)索引。缺少答案时取消该请求;不可用的 kind 取消 agent 请求并使场景失败。 + +**`src/normalize.ts`**:纯归一化器,按策略不含钩子。当未来的事件携带新的易变字段(如审批耗时),共享归一化器在同一个变更中学会它,保持「归一化」的含义只有一个归属地,而非各套件各自扩展清洗逻辑。 + +**`src/suite.ts`**:`Scenario` 类型与 `defineAcpSnapshotSuite(options)`,注册逐场景比对、record/refresh 的 fixture 回写、header pin 及其实时一致性守卫,以及 fixture 守卫块(无 orphan 场景目录、必需文件齐全、每个 class 恰好一个 pin、每个 JSONL 是 `scrubSystemPrompts` 的不动点、非 pinning 的 fixture 也是 `scrubRequestHeaders` 的不动点)。pinned-header 契约([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每个 header class 恰好标记一个 `pinsHeader` 场景,其 `system-prompt.golden.md` 与 JSONL 工具列表将组合后的 header 拆分为可评审的产物;一致性守卫将二者与该 class 中每个实时 header 进行比对。纯辅助函数(`childFixturePaths`、`fixtureContext`、`normalizedHeaders`、`normalizedSystemPrompts`、`formatSystemPromptSnapshot`、`headerDeltaCount`)从模块导出,以便直接进行单元覆盖。 + +## 曾考虑的替代方案 + +- **将模块复制到每个示例中**:正是本 RFC 要阻止的分叉。record/guard 逻辑恰恰是必须在各套件间逐字节一致的代码,而 examples 在覆盖率门禁之外,因此每份副本也无法被度量。 +- **在 `examples/` 下建共享模块目录**:代码仍在覆盖率门禁之外,且需要跨示例边界的相对导入,违背包名导入约定;`examples/` 的叶子节点按设计保持精简。 +- **在 `dsh-acp-demo` 中导出 `/testing` 子路径**:将测试基础设施耦合到产品包的公开接口与依赖集中;`packages/support/` 正是为真实但兼容性要求较低的开发/测试包而设,`dsh-llm-replay` 是先例,本包是其补全。 +- **导出原始测试体函数而非套件工厂**:每个示例将重新拥有 `describe`/`it` 骨架(每套件约 80 行注册样板),却无灵活性收益;工厂让消费方只需一张场景表加一次调用,导出的纯辅助函数在工厂设计内保留了单元可测性。 +- **可注入的 ACP `Client` 工厂取代声明式 `permissionAnswers`**:灵活性最大化,但将 SDK 客户端构造泄漏给每个消费方,并在正被统一的层面重新引入逐示例漂移;声明式队列让 `input.json` 保持为唯一的脚本化接口,且可被 golden 归一化。 +- **泛化到 ACP 之外(传输无关的快照 harness)**:不存在第二种传输;harness 端到端都是 ACP 形态(SDK 客户端、JSON-RPC 帧、`session/update` 等待器),推测性的抽象会在没有消费方之前就拆出一个 seam。 + +## 测试 + +提取保留了所有既有 ACP golden 的每一个字节。包的 `src/` 通过脚本化的 ACP 子进程实现逐文件 100% 覆盖:harness 测试覆盖每个步骤操作、两个预期错误分支、权限选择/回退/不可能选项、环境变量转发、工作区种子注入与收集排序/噪声/回退;suite 测试对已提交的合成 fixture 执行 replay,并对临时副本执行 record,加上纯辅助函数的测试。两个结构上不可达的守卫保留了有理由的覆盖率排除。fake agent 将 `session/new` 的 cwd 替换进日志,包括 Darwin 的 `/var` realpath 行为,与真实 bin 一致。 + +## 后果 + +新示例只需一张场景表加 fixture 即可获得完整的快照层——sandbox 分支从 master 合并后添加自己的套件(自己的 pin 场景、自己的覆盖层、通过 `test:snapshot:record` 生成 fixture、通过 `permissionAnswers` 指定审批答案)。代价:`suite.ts` 导入 vitest,因此该包只能在 vitest 运行中被导入——这是其他包没有的形态,已在其 README 中声明;每个套件 pin 自己的约 8 KB header fixture(真正不同的组合理应有自己的 pin;相同的组合会被该套件的一致性守卫捕获);e2e 启动器的重复仍然存在(`TODO(acp-test-harness)`)——当该迁移落地时,harness 是提取目标。 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml new file mode 100644 index 0000000000..5b46f88338 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-16-typed-event-schemas.md: 8d14c3b2d90d8dcf295e122e95267c2c0d7b2a17 +2026-06-16-typed-event-schemas.zh.md: 87bd6b89a2332b7a3a609a9c36d1e9835e42a0b1 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index 9eb4c585a4..8d14c3b2d9 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -1,5 +1,7 @@ # RFC: Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern) +English | [中文](2026-06-16-typed-event-schemas.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md new file mode 100644 index 0000000000..87bd6b89a2 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -0,0 +1,77 @@ +# RFC:事件词汇的运行时 schema(Zod 与 merge-extensible-map 模式之争) + +[English](2026-06-16-typed-event-schemas.md) | 中文 + +Status: proposed + +## 问题 + +harness 将其核心词汇——内容块、消息来源、结束原因、轮次触发器、轮次结束原因与会话事件——建模为 **merge-extensible map**:一个 TypeScript `interface`(如 `SessionEventMap`、`ContentBlockMap`),插件通过声明合并对其扩展,公开联合类型以 `Map[keyof Map]` 派生。这是本仓库的通用扩展模式,记录在 [docs/architecture.md](../../../architecture.md) 中("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`"),并被 `defineTool` 的 `InferArgs` DSL 与 `assertNever` 穷尽性约定所依赖。 + +该模式**仅存在于编译期**。类型在运行时消失:没有 schema 对象可供校验传入值、解析不可信输入或在运行时枚举。[会话持久化契约](../../implemented/architecture/2026-06-14-session-persistence.md)暴露了两个后果: + +1. **持久化将 `event.data` 视为不透明 JSON。** JSONL/SQLite 后端对每个事件逐字 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性——拒绝 BigInt、函数、循环引用、非有限数等),而**不是**结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在之后被消费方的 `switch` 处理时才可能被发现。 +2. **插件新增的变体没有运行时契约。** 一个通过声明合并添加新 `SessionEventMap` 键的插件,在自身代码中获得了编译期类型,但没有任何机制校验它产出的值是否匹配它声明的形状——无论在生产端、持久化边界还是重新加载时。 + +由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化边界与插件边界拥有运行时 schema 而非被擦除的类型。 + +本 RFC 界定这一问题的范围,不提出具体实现。 + +## 为什么这不是一个持久化变更 + +很容易把「用 Zod 做序列化」理解为对 `dsh-session-persistence-jsonl/src/format.ts` 的局部改动。但它不是,原因在于一个结构性事实:**插件无法通过声明合并扩展一个 Zod schema。** 声明合并是 TypeScript 的编译期机制;Zod schema 是运行时值。要用 Zod 校验事件,你需要一个**运行时注册表**,每个产出事件的包向其贡献自己的 schema(如 `ctx.sessionEvents.register('compaction/marker', z.object({…}))`),每个消费方从中读取。这个注册表——而非持久化后端——将成为词汇的真源,取代 merge-extensible interface。 + +因此真正的提案是:**用运行时 schema 注册表替换编译期的 merge-extensible-map 模式,覆盖全仓库。** 这是一次核心词汇的重新设计。 + +## 影响范围(实测) + +将事件/词汇表面迁移到运行时 schema,至少涉及: + +- **六个 merge-extensible map**(约 370 行核心类型):`ContentBlockMap`、`MessageSourceMap`、`FinishReasonMap`(在 `dsh-llm` 中);`TurnTriggerMap`、`TurnEndReasonMap`、`SessionEventMap`(在 `dsh-session` 中)。 +- **约 10 个 `declare module` 扩展点**,分布在 `dsh-agent`、`dsh-agent-loop`、`dsh-bash`、`dsh-llm`、`dsh-session`、`dsh-session-persistence`、`dsh-system-prompt`、`dsh-tools` 中——每个都将从声明合并改为运行时 `register()` 调用。 +- **事件生产端**——agent loop 中 16 处 `session.append(...)` 调用点——形状不变,但现在在边界处被校验。 +- **约 7 个 switch 消费方**,按这些联合类型分支:`deriveMessages`(`dsh-session`)、`BlockAssembler`(`dsh-llm`)、`dsh-invariants` 插件、两个 LLM 适配器(`dsh-llm-deepseek`、`dsh-llm-pi-ai`)以及工具 schema 层(`dsh-tools`)。`assertNever` 对封闭联合的穷尽性 vs 对可扩展联合的 fall-through 约定(一条已文档化的 lint 规则)需要重新考量——运行时变体不具备静态穷尽性。 +- **`defineTool` 的 `InferArgs` DSL**(`dsh-tools`),它从编译期 schema 规格派生零强制转换的 `execute` 参数类型——这是当前方案的标杆用例。 +- **文档**:architecture.md(该模式被描述为基础性的)、[开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md),以及任何引用该模式的 RFC。 + +这是一次仓库级别的词汇重新设计,不是持久化的实现细节。 + +## 曾考虑的替代方案 + +### A. 维持现状——merge-extensible 类型 + 持久化边界的 `isJsonValue` +保留编译期模式。持久化继续使用不透明 JSON + 可序列化性守卫。插件通过声明合并扩展;事件*形状*的正确性由生产方负责,在编译期由 TypeScript 强制,在开发模式下由 `dsh-invariants` 插件的结构检查强制。 + +- **优点**:零变更;插件扩展只需一行 `interface` 声明合并,具备完整类型推断且无运行时注册仪式;无新运行时依赖;`defineTool` DSL 与 `assertNever` 穷尽性保持正常工作。 +- **缺点**:持久化边界和插件 seam 处无运行时结构校验;格式错误但仍为合法 JSON 的数据被延迟捕获。 + +### B. 仅对 header/封闭形状做校验(schemastery),事件保持不透明 +仅对那些已有手写类型守卫的真正封闭形状加强校验——例如 JSONL 的 `HeaderLine` 守卫(`isHeaderLine`)——使用 **schemastery**(本仓库现有的 schema 库,已用于每个插件的 `static Config`)。merge-extensible 事件联合保持不变。 + +- **优点**:改动小,契合既有约定(schemastery,非新库);用声明式 schema 替换封闭形状上的手写守卫;无核心重设计。 +- **缺点**:不解决事件数据的校验问题;仅固定的元数据记录得到改善。 + +### C. 为整个词汇建立运行时 schema 注册表(Zod 或 schemastery) +用运行时注册表替换 merge-extensible map,生产方向其贡献 schema,持久化/消费方据其校验。 + +- **优点**:持久化边界与插件 seam 处有真正的运行时校验;单一真源;支持通用工具(自动生成文档、模糊测试、协议格式检查)。 +- **缺点**:上述完整影响范围;**Zod 目前不是直接依赖**(仅作为 `@earendil-works/pi-ai` 的传递依赖),本仓库选定的 schema 库是 **schemastery**——广泛引入 Zod 本身就是一个依赖决策;声明合并的人体工学(一行插件扩展、完整推断)被运行时注册 + 手动类型接线取代;`assertNever` 穷尽性保证弱化(运行时变体不具备静态穷尽性)。 + +## 提案 + +暂缓。如果需要在持久化边界做运行时校验,**方案 B**(用 schemastery 校验封闭的 header 与元数据形状)是既有约定内的适度步骤。**方案 C** 是一项架构决策,需要自己的实现 RFC,包括在 Zod 与 schemastery 之间做出选择。 + +## 验收标准 + +- 方案 C 只能通过自己的实现 RFC 推进,绝不作为持久化的附带效果。 +- 如果采纳方案 B,封闭的 header/元数据形状(JSONL 的 `isHeaderLine` 守卫及同类)改用 schemastery 校验以替代手写守卫,merge-extensible map 保持不变。 + +## 风险 + +- 暂缓意味着事件 `data` 在持久化边界仍无结构校验:格式错误但仍为合法 JSON 的数据被延迟捕获,由消费方的 `switch` 处理——这是现状的代价,有意接受。 +- 如果方案 C 最终被采纳,人体工学损失是实际的:一行声明合并变为运行时注册加手动类型接线,`assertNever` 的静态穷尽性保证弱化。 + +## 待解问题 + +- 如果采用注册表,schema 库选 **schemastery**(已在依赖树中,已是配置 schema 库)还是 **Zod**(生态更丰富,目前仅为传递依赖)?同时维护两个 schema 库本身就是成本。 +- 能否采用混合方案:保留编译期推断(使 `defineTool` 和插件 DX 不受影响),同时为每个变体添加*可选*的运行时 schema,仅在持久化/协议边界校验而非每次进程内 append 时校验? +- `dsh-invariants` 插件在开发模式下是否已覆盖了足够多的运行时形状缺口,使得边界校验仅在面对真正不可信的输入(如重新加载被外部修改的日志)时才有必要? diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml new file mode 100644 index 0000000000..edc5a3604d --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-generic-long-running-tool-runtime.md: ea773a651b5aeec87179aac2ed419f176486977f +2026-06-20-generic-long-running-tool-runtime.zh.md: d50eb6858c11b9c98817b24bfe40f4e2c780f4b9 diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md index 5ed9c6354e..ea773a651b 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -1,5 +1,7 @@ # RFC: Extract a generic long-running tool runtime +English | [中文](2026-06-20-generic-long-running-tool-runtime.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md new file mode 100644 index 0000000000..d50eb6858c --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -0,0 +1,43 @@ +# RFC:提取通用的长时运行工具运行时 + +[English](2026-06-20-generic-long-running-tool-runtime.md) | 中文 + +Status: proposed + +## 问题 + +bash 能力 seam 同时支持前台命令和长时运行的后台任务。后台支持体量不小:抽象执行器暴露 `start`、`get`、`ownerOf`、`list`、`readOutput`、`kill` 和 `onTaskDone`;本地执行器跟踪任务、增量读取、owner token、进程清理和完成监听器;模型侧看到三个工具(`bash`、`bash_output`、`bash_kill`);工具插件将完成通知注入回所属 agent 的会话。本地执行器用 owner token 隔离任务访问,因为可预测的全局 task id 会带来跨会话的读取/终止风险。 + +[工具实操手册](../../../cookbook/adding-a-tool.md)已经指出了真正的设计异味:后台 bash 实质上是寄居在一个工具内部的通用长时运行工具基础设施。如果未来的工具也需要后台执行、轮询、终止、所有权和完成通知,这些语义不应藏在 `dsh-bash` 里。 + +## 提案 + +将长时运行任务的语义从 bash 上方抽出,放入一个与工具无关的运行时。bash 仍然能运行后台命令,但不再拥有 task id、ownership token、轮询、取消、完成通知以及模型侧「读取/终止此任务」命令等通用概念。 + +该运行时应拥有: + +- 稳定的 task id 与 owner token,按调用方的会话/agent 键控。 +- 注册一个长时运行任务,附带增量输出的生产者和一个完成 promise。 +- 通用的 read/cancel/list 操作,对所有工具使用相同的跨会话授权规则。 +- 向所属会话注入完成通知。 +- 待处理/运行中/已完成任务状态的展示钩子,bash 只提供命令特有的标签和输出格式化。 + +`dsh-bash` 随后只保留 bash 特有的执行契约:将请求解析为命令规格、运行前台命令,或启动进程并将其流/进程句柄交给通用运行时。`dsh-tool-bash` 保留模型侧的命令工具,但后续操作变为通用的长时运行工具操作(或 bash 向其注册的共享工具层),而非定制的 `bash_output`/`bash_kill` 管道。 + +## 当前 seam 消费情况 + +当前消费方划分清晰:`dsh-tool-bash` 使用完整的前台/后台 seam,而钩子桥接只使用前台的 `resolve` 和 `run`(带受信的 `stdin` 与 `env`)。`get` 和 `list` 仅在测试中使用;`BashTask.done` 仅在实现内部用于 dispose(资源释放),生产环境的完成通知走 `onTaskDone`。提取出的运行时应暴露单一的公开完成机制,保留钩子所需的简单前台路径,并决定后台的 `timeoutMs` 是否属于 `start`。如果运行时拥有进程 spawn,还应集中处理目前重复的凭证清洗逻辑。 + +## 验收标准 + +- bash 特有的包不再定义通用的任务注册表、owner-token 授权、轮询、取消或完成通知机制。 +- 一个共享的长时运行任务服务或工具层拥有这些语义,并作为未来任何具备后台能力的工具的文档化路径。 +- bash 的后台行为仍可通过共享层使用,测试证明跨会话隔离依然成立。 +- ACP 和快照 fixture(测试前置数据)通过共享的任务词汇渲染后台 bash,而非通过 bash 独有的生命周期语义。 +- [工具实操手册](../../../cookbook/adding-a-tool.md)将长时运行工具指向共享运行时,而非告诉每个工具自行发明任务协议。 + +## 风险 + +bash 包失去了对一个已经可用的后台任务实现的本地所有权,实施 PR 可能暂时搅动模型侧的工具名称或 transcript(文本记录)展示。如果最终结果是留下一份后台任务契约、而非让每个未来的长时运行工具克隆 bash 的私有协议,这种搅动是值得的。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml new file mode 100644 index 0000000000..2c12df37b1 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-30-pre-tool-input-rewrite.md: add84bfc76434eb25870f09e71860279663d291e +2026-06-30-pre-tool-input-rewrite.zh.md: c636cf42d5c55f0ee1192a46283f8cdbe8c4cadc diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md index 9657512082..add84bfc76 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md @@ -1,5 +1,7 @@ # RFC: Pre-tool input rewrite — a consistent design +English | [中文](2026-06-30-pre-tool-input-rewrite.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md new file mode 100644 index 0000000000..c636cf42d5 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md @@ -0,0 +1,53 @@ +# RFC:工具执行前输入改写——一致性设计 + +[English](2026-06-30-pre-tool-input-rewrite.md) | 中文 + +Status: proposed + +## 问题 + +[拦截 seam RFC](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道 allow/deny/ask 门禁,作用于身份已受保护、参数已被深度冻结的执行对象。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的改写机制。改写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 + +## 问题本质:执行前参数的三个读取方 + +在 agent loop(智能体循环)中,工具调用的参数在工具执行之前就已被提交到日志并被活跃消费方读取: + +1. **`assistant/message`** 在工具分发之前追加——它是 `deriveMessages()` 回放时的模型历史来源,因此携带的是模型自身生成的工具调用参数。 +2. **`tool/call`** 是持久化的审计记录,在 `ctx.tools.execute()` 之前追加。 +3. **展示层实时读取 `tool/call.arguments`**:ACP 桥接会记住这些参数并传给 `presentResult`;`dsh-tool-bash` 从中派生卡片标题、rawInput、cwd 以及终端/后台的处理方式。 + +如果只做执行层面的改写,UI 会展示一条命令而实际运行的是另一条,并且结果会对着错误的参数渲染。注册表目前阻止了这种失败模式:它对 `arguments` 做 structured-clone 并深度冻结,将执行身份属性设为不可写,且不暴露任何可替换它们的测试 shim 或监听路径。改写设计必须保持这一受保护的身份边界,而非削弱它。 + +## 提案 + +改写是一次「身份构造前的一致性事务」。当钩子提供 `updatedInput` 时,有效值必须在注册表构造不可变的 `ToolExecution` 之前确定,并原子性地反映到全部三个读取方: + +- `tool/call` 审计事件记录**改写后**的参数(原始参数保留在一个 sidecar 字段中用于审计追踪——钩子改变了调用,原始参数和生效参数都是值得保留的事实)。 +- 派生历史中的 `assistant/message` 必须与实际执行一致——待评估的选项:就地改写 assistant 消息中的工具调用块(改变模型「看到自己说过的话」),或记录一条单独的修正由下一次请求携带。CC 的模型是让模型看到改写已生效。 +- 展示层(`presentCall`/`presentResult`)读取改写后的参数,UI 展示的是实际运行的内容。 + +在 `PreToolDecision` 当前的触发点上做扩展不够:此时两条持久化记录都已存在,执行身份已受保护。实现必须要么将相关决策移到日志提交之前,要么在待处理的模型调用上增加一个专门的更早期改写决策。当循环将生效参数提交到历史和审计之后,再按常规构造不可变执行对象,并照常运行现有的 allow/deny/ask 与工具流水线。 + +## 曾考虑的替代方案 + +### 为什么不直接修改执行对象? + +允许 pre-execute 监听器赋值 `exec.arguments` 只能提供执行层面的改写,模型历史、审计和展示层不会跟着变。保持身份受保护使得这种局部行为无法被表达。在一致性事务实现之前,CC/Codex 桥接对 `updatedInput` 只做日志记录并发出警告,而非声称已兑现;循环分发处的 `TODO(pre-tool-input-rewrite)` 锚定了这个缺失的更早阶段。 + +## 验收标准 + +- 请求的改写在 `ToolExecution` 身份创建之前完成解析,并原子性地反映到全部三个读取方:`tool/call` 审计记录改写后的参数(原始参数保留在 sidecar 字段)、派生历史与实际执行一致、展示层渲染改写后的参数。 +- 生效的 `ToolExecution.arguments` 在 pre-policy、guards、dispatch、post-policy 和最终观测的全过程中保持深度冻结且不可写;不引入任何可变 shim。 +- CC/Codex 桥接兑现 `updatedInput`,不再输出忠实但降级的警告。 + +## 风险 + +- 改写 `assistant/message` 中的工具调用块会改变模型「看到自己说过的话」;是否有提供方在回放时拒绝这种改写,是一个必须在决策形态冻结前通过实验验证的开放问题。 +- 更早期的改写阶段改变了 `assistant/message`、`tool/call`、钩子审计事件与执行之间的顺序关系;设计必须固定这一顺序,同时不削弱轮次封闭性或 call/result 邻接性。 + +## 开放问题 + +- 改写 `assistant/message` 中的工具调用块是否会破坏某些提供方在回放时的预期?还是记录一条单独的修正更安全? +- 原始参数是否应保留在 `tool/call` 事件(审计)上?如果是,放在哪个字段? +- 改写决策是移到日志提交之前,还是成为一个专门的更早期 seam?现有的 pre-tool allow/deny 钩子如何避免运行两次? +- 这与未来的权限 `ask` 流程(用户批准一个被改写的调用)如何交互? diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml new file mode 100644 index 0000000000..d7b4dcf71f --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-07-claude-code-and-codex-subagent-backends.md: 1ebf01dd8df0980f6c464be8b27033bdfab942f3 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 0ed5b42bc9d60b54ac610a8f34f8261f3aeefaed diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index bf4e85e020..1ebf01dd8d 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -1,5 +1,7 @@ # RFC: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents) +English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md new file mode 100644 index 0000000000..0ed5b42bc9 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -0,0 +1,89 @@ +# RFC:Claude Code 与 Codex subagent 后端(进程外委派至外部编码 agent) + +[English](2026-07-07-claude-code-and-codex-subagent-backends.md) | 中文 + +Status: proposed + +## 问题 + +为 Claude Code 和 Codex 添加隔离的 subagent 提供方。既有的[命名提供方 seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [ACP 后端](../../implemented/feature/2026-06-22-acp-subagent-backend.md)已确立了进程边界的形状。harness 的一个轮次应当能够将一个自包含的任务委派给上述任一产品,并接收其最终回答,同时不暴露父进程的密钥,也不继承来自 `~/.claude` 或 `~/.codex` 的宿主配置。 + +## 方案 + +两个兄弟提供方包(ACP 后端的结构变体),加一次提取: + +- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其捆绑的 `claude` CLI 作为子进程 spawn)。提供方名称 `claude-code`:子进程是 Claude Code 这个**产品**,而非 Anthropic 模型适配器——"claude" 保留给未来的 `dsh-llm` 适配器。 +- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议,使用包内一个手写的换行 JSON 客户端(约 200–300 行)驱动一个 thread/turn。 +- `@deepseek-ai/dsh-subagent-process`:纯库(`subagent-inprocess` 先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`SENSITIVE_ENV_PATTERN`/`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose 阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 + +两个提供方遵循 ACP 后端契约:每次 `start` 创建一个全新子进程、一次 prompt 往返、不继承父上下文也不声明可选能力、忽略 `request.parent` 和 `request.agentOptions`、使用随机品牌 agent id。`result` 永不 reject;子进程失败映射为 stop reason,原始错误送入 logger。每个提供方在不同的工具名下挂载 `dsh-tool-subagent`。工具结果是唯一新增的模型可见产物,因此不需要新的会话事件;工作区变更仍是 transcript 回放之外的环境副作用。 + +## 已验证的接口事实(固定版本) + +两个集成面在本提案之前均已针对固定实现进行了验证——读取类型与捆绑源码、运行 keyless spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门控、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都重跑 keyless 套件以验证真实加载路径——运行时则通过大声失败来保障:协议层的意外通过 `onError` 结算为 `error`,绝不静默异常。 + +**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会**替换**子进程环境(不与 `process.env` 合并),这正是清洗所需的行为。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,若子进程忽略则约 2 秒后发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;二者均不在本 RFC 范围内。 + +**codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 + +- 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的 turn;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 +- 审批为服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 +- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证并大声结算 `error`,而非等待 turn。 +- 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),且 `ephemeral: true` 的 thread 完全不留会话文件。 + +## 隔离与凭证 + +认证仅使用 API key。每次运行使用一个全新的配置目录(Claude Code 用 `CLAUDE_CONFIG_DIR` 配合 `settingSources: []`,Codex 用 `CODEX_HOME`),dispose 时尽力删除;配置也可选择一个持久目录。共享的子进程环境辅助函数转发 `PATH`、`HOME`、`TMPDIR`、locale、代理设置等普通值,移除凭证形状的名称,并叠加显式的 `config.env`。Claude Code 通过该叠加接收 API key,Codex 则通过 `account/login/start` 接收,而非手写认证文件。 + +## 权限与审批策略 + +每个后端暴露其引擎的原生策略词汇。Claude Code 默认 `permissionMode: default` 配合 `permission: reject`;Codex 默认 `sandboxMode: read-only`、`approvalPolicy: never`,以及相同的拒绝回退。示例可选择启用 `acceptEdits` 或 `workspace-write`。已知的审批、用户输入和 elicitation 请求接收配置的应答;未知方法接收 method-not-found,未知通知被消费。没有 prompt 到达人类,子进程也不会因等待不可用的输入而无限挂起。 + +## StopReason 映射 + +Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不算成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。 + +活性姿态,明确声明:teardown 时序是配置项,turn 时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意**不设** turn 时长或启动超时——与 ACP 一致:turn 期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent turn 合理地可达数分钟,且 Codex 认证预检已消除了唯一经验证的必然挂起场景;需要墙钟上限的部署从父进程取消即可。 + +## 测试 + +每个适用层级都要求覆盖: + +- **Keyless 单元/集成:** 通过真实 SDK 驱动一个假 Claude CLI,通过真实 wire 客户端驱动一个脚本化的 Codex app-server。在逐文件 100% 覆盖率下,覆盖往返、每个 stop 映射、两条取消路径及预中止、权限策略、未知消息、spawn 失败、reload 清理、导出形状、清洗后的环境、临时目录删除,以及 Codex 认证预检失败。 +- **带 key 的 e2e:** 每个真实引擎在 `acceptEdits` 或 `workspace-write` 下执行文件操作;跳过时命名缺失的二进制文件或 key,并断言无残留子进程。 +- **快照:** 以 `TODO(claude-code-subagent-replay)` 和 `TODO(codex-subagent-replay)` 延后,等待 [subagent 回放 RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md) 描述的进程特定回放形状。 + +## 曾考虑的替代方案 + +### 为什么不用官方 `@openai/codex-sdk` 而是手写客户端? + +dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),且仓库先例(`hook-protocol`)是自有精简协议核心而非包装他人运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。 + +### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)? + +Claude Code 自己的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在**执行引擎**之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置中,保持 `dsh-tool-subagent` 文档化的一提供方一工具契约。人格式的类型选择器应当是针对工具的独立 RFC,而非后端。 + +### 为什么不用登录态凭证和用户自己的配置? + +继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill、MCP 服务器)会让子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打一个隐式例外。仅 API key 加强制配置目录隔离保持了运行的可复现性;需要共享状态的部署可以刻意将配置目录字段指向一个持久目录。 + +### 为什么不为 Claude Code keyless 测试注入一个驱动 seam? + +注入一个假 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部的——已被 spike 消除:假 CLI harness 今天能对着真实固定版本的 SDK 工作。如果 SDK 升级破坏了 mock,keyless 套件会让升级 PR 失败,这正是门禁在发挥作用。 + +### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端? + +社区 shim 将两个引擎包装为 ACP,这会让它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方第三方层,抹掉了本 RFC 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏换取第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。 + +## 验收标准 + +在同时配置了两个引擎和 key 的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终回答,父会话日志中仅有 `tool/call` + `tool/result`。Keyless 套件在无凭证环境中以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程 env、dispose 后无残留临时配置目录)以及 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内静默,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。 + +## 风险 + +- `codex app-server` 以 CLI flag 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、消费未知方法/通知而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑 keyless 套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。 +- Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过 keyless 套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生口)。 +- SDK 的 optionalDependencies 每平台约 280MB——已接受,且限制在单个后端包内。 +- SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其存在;e2e 保留无残留进程断言。 +- Codex 是部署前置条件(无 npm 捆绑的二进制文件);缺失或不兼容的二进制文件表现为大声的 spawn/协议 `error`,而非版本探测。 +- 每次运行付出一个全新子进程的代价,且仅最终回答浮出——思考、工具卡片和用量被消费后丢弃;池化、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意延后。 diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml new file mode 100644 index 0000000000..d9853d91fe --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-08-interactive-side-sessions.md: 250a906d9ec339399a0e0e29e70b2b8dc189fa72 +2026-07-08-interactive-side-sessions.zh.md: 17d416e2297320e8dfa238569230ecdec91dfa32 diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md index 7caceed4ba..250a906d9e 100644 --- a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md @@ -1,5 +1,7 @@ # RFC: Interactive side sessions and merge-back +English | [中文](2026-07-08-interactive-side-sessions.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md new file mode 100644 index 0000000000..17d416e229 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -0,0 +1,41 @@ +# RFC:交互式侧会话与合并回写 + +Status: proposed + +[English](2026-07-08-interactive-side-sessions.md) | 中文 + +## 问题 + +用户可能希望在不改变当前会话主上下文的前提下探索一个问题。现有原语无法提供这种产品形态:[session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) 创建的是一个无关联的会话,而 [fork subagent](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 是模型驱动的任务,其 transcript(文本记录)会折叠为一条工具结果。两者都不能给用户一个独立的对话,也都不能将结论带着来源信息写回父会话。 + +## 提案 + +**侧会话(side session)** 是一个普通的活跃会话,从源会话最后一个已完成轮次处 fork 而来,附属于自己的 agent,以只读顾问的角色运行,并能够**合并回写**一条精炼笔记。 + +- **Fork 并附属:** 以父会话的均衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或 session-store 方法。 +- **顾问框架:** 创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,以保留提供方对继承历史的前缀缓存。 +- **合并回写:** 向子会话请求一条有长度上限的交还内容,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求会在其日志位置看到它,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md),无需新增会话事件。 +- **呈现:** 调用方式、会话切换与交还内容的渲染属于首个客户端拥有的界面。本 RFC 仅规定与界面无关的机制。 + +回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据不在本 RFC 范围内。一次 live-adapter 原型验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 + +## 曾考虑的替代方案 + +- **使用 subagent seam:** 否决。侧会话是用户驱动的、客户端可见的,且可能比父会话的一个轮次存活更久;subagent 是模型驱动的运行,返回一条工具结果。 +- **修改子会话的系统提示词:** 默认否决,因为任何字节变化都会从第零个 token 起使前缀缓存失效。部署方仍可选择更强的隔离。 +- **新增 `sidechat/*` 事件:** 推迟。插件来源的 `context/message` 已经提供持久性、来源信息与回放能力;只有当某个界面需要区分渲染时,专用事件才有正当理由。 +- **现在就绑定协议界面:** 否决。当前 UI 由客户端拥有。实时呈现最终必须从持久化消息派生,以确保回放渲染出相同的记录。 + +## 验收标准 + +- Fork 不改动源会话,并创建一个子会话,子会话具有均衡的已完成轮次前缀、`parentSession`、`seedLength`,以及逐字节一致的系统提示词。 +- 顾问框架在子会话追加历史的头部恰好添加一条插件来源的 `context/message`,而非修改其系统提示词。 +- 合并回写恰好添加一条有长度上限的 `context/message`,来源为 `plugin: sidechat`;父会话的下一次请求与回放在相同位置看到它。 +- 父会话与子会话并发运行,日志与流之间无串扰。 +- 单元测试覆盖 fork/attach 与合并回写;快照覆盖率随首个绑定界面一起落地。 + +## 风险 + +- 只读行为在 `tools/pre-execute` 拒绝门禁强制执行之前仅为建议性的;[拦截 seam](../../implemented/feature/2026-06-30-interception-seams.md) 可以在不改变本机制的前提下添加该门禁。 +- 经过压缩(compaction)的源会话 fork 出的是其压缩视图,因此绑定界面应当告知用户:子会话继承的是摘要而非被替换的轮次。 +- 反复的交还内容会消耗父会话上下文。每次合并的长度上限约束了单条笔记的大小;后续整合属于压缩的职责。 diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml new file mode 100644 index 0000000000..349767710c --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-10-sqlite-session-query-provider.md: 8b67baf420433feca9d5cd09d58852bb9b1545a9 +2026-07-10-sqlite-session-query-provider.zh.md: de97e59ac6f2f90a738ff5c8b9c2872d54ba3954 diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md index acfdf23bee..8b67baf420 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -1,5 +1,7 @@ # RFC: SQLite FTS5 session search +English | [中文](2026-07-10-sqlite-session-query-provider.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md new file mode 100644 index 0000000000..de97e59ac6 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md @@ -0,0 +1,53 @@ +# RFC:SQLite FTS5 会话搜索 + +[English](2026-07-10-sqlite-session-query-provider.md) | 中文 + +Status: proposed + +## 问题 + +精确读取服务 `ctx.sessionQuery` 有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不能在每次查询时扫描所有事件;同时,当前活跃会话需要一个比上次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤、分页、取消以及重建行为。 + +如果把这些关注点拆分到一个推测性的 provider 协调器和一个数据库实现中,会产生两个耦合的协调状态机。第一个真实实现应当将源观察、提取、SQLite 事务、generation 管理和查询作为一个完整生命周期来拥有。 + +## 提案 + +在精确读取包旁新增 `@deepseek-ai/dsh-session-query-sqlite`。该包将暴露一个搜索服务或以其实际消费方所需的最小 API 扩展现有服务族;第一阶段不预先承诺 provider 注册协议。它将依赖 `ctx.sessions` 和可选的 `ctx.sessionPersistence`,拥有一个独立的派生 SQLite 数据库,并复用规范的 `foldSurface()` 分类。 + +实现拥有一个串行化的协调/数据库事务状态机。一次事务观察权威的持久化元数据和活跃快照,提取语义文档,更新派生表,推进相关的游标 generation,并执行或启用相应的查询。没有第二个服务维护并行的指纹、脏标记、活跃 ID 集合或失效 generation。 + +持久化文档在重启后保留。活跃覆盖层是连接局部的,为同一会话遮蔽持久化行,在活跃所有者或数据库关闭时消失。派生数据库与规范持久化分离,因此索引重置、损坏、分词器变更和 schema 变动不会危及持久的对话日志。 + +## 随实现确定的搜索语义 + +实现必须从可执行的用例出发定义跨会话和会话内两种搜索范围。每个可搜索事件是一个文档,包含会话元数据、事件元数据、surface 分类、归一化语义文本和有界的纯文本摘要片段。会话级结果按其最强匹配事件分组;数值化的后端分数保持私有。 + +过滤器在排序之前编译为参数化 SQL。查询语法作为数据处理。排序包含稳定的平局字段。不透明游标绑定到归一化的请求形状和最小相关 generation;不相关的会话变更不应使会话内游标失效。取消操作必须停止调用方等待,并在运行时允许的范围内中断 SQLite 工作。 + +分词器选择仍是一个实现实验。FTS5 trigram 支持子串召回,但会拒绝短于三字符的有用词项并增大索引体积;提案在将其纳入契约之前,必须对比默认 Unicode 分词器做基准测试。 + +## 提取与协调 + +该包首先为消息、推理(reasoning)、工具调用/结果、被拦截的提示词、上下文、steering(中途引导)、待办事项和错误/状态详情提供第一方语义提取。结构性事件和流式分片不贡献文档。未知的声明合并事件/内容类型保持不可搜索,除非有真实的扩展消费方证明需要公开的提取器注册表。 + +协调可以使用稳定指纹来避免重写未变更的持久化会话,但指纹的计算和存储由数据库包拥有。当源观察或提取失败时,它绝不能报告某行为最新。provider-schema 不匹配只重置派生数据库;普通的源变更使用事务性 upsert/delete。已挂载但不可读的持久化使受影响的搜索失败,但不影响规范写入或已知的活跃精确读取。 + +## 曾考虑的替代方案 + +- **在规范持久化数据库中添加 FTS 表**:否决。可重建的索引不应与权威日志共享 schema/重置/故障边界。 +- **在第一阶段重新引入 provider 协调**:否决。只有一个计划中的实现,没有证据表明存在稳定的多 provider seam。 +- **立即持久化活跃覆盖层**:否决。活跃事件在现有检查点提交之前不是规范的。 +- **返回 BM25 分数**:否决。提供方特有的数值尺度在语料变化时不稳定。 + +## 验收标准 + +- 重启测试覆盖未变更、新增、已变更和已删除的持久化会话,且不重建整个索引。 +- 重新打开时保留持久化行并移除活跃行;活跃行先遮蔽、后显露其持久化基底。 +- 测试覆盖两种搜索范围、元数据过滤、surface 默认值、摘要片段、转义、确定性平局、分页、范围内的陈旧游标、取消、动态持久化挂载/卸载,以及事务失败后的恢复。 +- schema 不匹配只重置派生数据库。 +- 一个无 key 的端到端测试将真实的持久化后端与真实的 SQLite 搜索包组合使用。 +- 在移入 `implemented/` 之前,本 RFC 须修订为实际实现的分词器和公开 API。 + +## 风险 + +单一所有者比提供方无关的 seam 更简单,但初期可复用性较低。这是有意为之:第二个真实后端能揭示应当抽取什么。SQLite 运行时差异可能影响 FTS 排序和摘要片段,因此测试只能固定契约控制的排序和呈现。独立数据库增加了配置和生命周期工作,但保全了规范存储的安全边界。 diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml new file mode 100644 index 0000000000..6b40707fbf --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-13-stream-workflow-progress-through-tool-calls.md: 525f2793052a80d82de29d2d370cfd747d002af6 +2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: b5cc86df3ca42e513bda7e56b485a8287f275613 diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md index 200ed50b1e..525f279305 100644 --- a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md @@ -1,5 +1,7 @@ # RFC: Stream workflow progress through tool calls +English | [中文](2026-07-13-stream-workflow-progress-through-tool-calls.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md new file mode 100644 index 0000000000..b5cc86df3c --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md @@ -0,0 +1,43 @@ +# RFC:通过工具调用流式传输工作流进度 + +[English](2026-07-13-stream-workflow-progress-through-tool-calls.md) | 中文 + +Status: proposed + +## 问题 + +工作流引擎有意为 run、phase、narration 和子 agent 进度发出成对平衡的 `workflow/*` 观察事件,但目前没有生产消费方呈现它们。因此编辑器在最终结果到来之前只显示一张 pending 状态的工作流工具卡片,尽管引擎已经报告了当前活跃的 phase、脚本日志内容以及哪些子 agent 已启动或已完成。[dynamic-workflows 决策](../../implemented/feature/2026-07-05-dynamic-workflows.md)明确将 ACP 进度 UI 保留给这条事件流。 + +如果让 `dsh-acp` 直接监听工作流事件,会反转能力边界:通用的 UI 桥接层将依赖一个可选的工作流包(package),并对一个工具名做特殊处理。工具流水线已经拥有实时更新所需的路由信息(agent 和 call id),但只暴露了纯粹的 pending/final 展示器,因此长时间运行的工具没有提供方无关的方式在二者之间报告瞬态 UI 状态。 + +## 提案 + +为 `dsh-tools` 添加一条实时进度通道。注册表持有的 `ToolExecution` 新增 `reportProgress(view): boolean`,其中 `view` 是一个独立的、提供方无关的通用进度快照,包含可选的替换标题和面向 UI 的内容块。进度不能改变调用的 args 派生卡片标签、kind、原始输入、locations、terminal intent 或 diff intent;它只更新初始选定的展示形式中的实时标题/内容。执行活跃期间,该方法校验并快照 view,然后派发一个受限的、agent 作用域的 `tools/progress` 观察事件,携带权威的执行标识与快照。一旦 final-result 处理开始,方法返回 `false` 且不再派发,确保迟到的异步报告者无法覆盖终态卡片。观察者异常被记录但不会导致工具失败。 + +`dsh-acp` 以通用方式消费 `tools/progress`。它通过现有的 agent-to-session 映射解析执行所属的 agent,并为同一 call id 发出一条 in-progress 的 `tool_call_update`。由于报告仅在工具执行流水线内部可用,持久化的 `tool/call` 及其 ACP `tool_call` 始终先于第一条 update;在 `tools/result` 之前关闭报告者确保没有进度更新出现在 completed/failed 卡片之后。进度是实时 UI 状态而非模型输入或持久历史:会话回放继续从 `tool/call` 和 `tool/result` 重建 pending 与 final 卡片,无需重放瞬态更新。 + +`dsh-tool-workflow` 成为第一个生产者。每次工具执行在调用 `ctx.workflows.start()` 之前安装一个紧凑的事件捕获器,因为合法的引擎可能在 `start()` 内部同步发出进度。在调用返回之前,捕获器将观察到的事件按 `WorkflowRunInfo.id` 归约为候选状态;随后选取返回的 `WorkflowRun.id`、丢弃其他候选、报告累积的快照,并将后续匹配事件直接路由。如果 `start()` 抛出异常,捕获器被 dispose,其候选被丢弃。这在不向 `WorkflowStartRequest` 添加观察者关联、也不要求进度等到 `start()` 返回的前提下,保持了引擎的可替换性。 + +归约器消费现有的 start、phase、log、agent-start、agent-end 和 end 事件,报告一个替换快照,包含当前 phase、最新日志行、活跃子 agent 标签,以及 completed/failed/cancelled 计数。它不累积 narration transcript;已完成的子 agent 离开活跃集合、转为计数。`workflow/end`、工具结算或插件 dispose 移除归约器条目和事件捕获器。六种工作流事件、它们的元数据、成对的子 agent 生命周期、run handle、取消通道和观察者隔离保持不变;第三方观察者可继续直接消费它们。 + +更新工具执行/展示文档、生成的事件与 API 目录、工作流包文档以及工作流数据结构目录。ACP 集成覆盖率必须使用脚本化的模型边界对真实的工作流工具和 worker seam 进行测试;主 ACP 快照套件新增一个 workflow-progress 场景,因为此变更改变了面向编辑器的 transcript。 + +## 曾考虑的替代方案 + +**删除工作流观察面。** 在 [collapse-workflow 简化提案](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md)中被否决:这些事件及其平衡的生命周期是有意设计的,缺失的部分是消费方。 + +**让 ACP 直接了解工作流。** 这可以将 `WorkflowRunInfo` 映射到会话和卡片,但会使通用桥接层依赖一个可选能力,并绕过「工具拥有展示意图」的规则。工具进度通道为所有长时间运行的工具解决了同样的路由问题。 + +**将每次进度更新持久化为会话事件。** 这会使实时 narration 可回放,但会用一种权威持久结果已由 tool call/result 对表达的状态永久膨胀日志。如果可恢复的工作流进度成为产品需求,它需要一个工作流日志化设计,而非伪装成持久事实的 UI 快照。 + +## 验收标准 + +- `ToolExecution.reportProgress()` 由注册表持有、agent 作用域、快照化、观察者隔离,且在终态处理开始后返回 `false` 而不派发。 +- ACP 将进度路由到正确实时会话中的正确调用;不同会话中的并发工作流不能串扰,且 `tool_call_update` 不会出现在其 `tool_call` 之前或终态更新之后。 +- 工作流进度显示当前 phase、最新日志行、活跃子 agent 和结果计数,同时保持所有现有 `workflow/*` 事件和 run 语义;一个在 `start()` 内部同步发出 start、phase、log、child 和 end 事件的 seam 测试引擎不会丢失任何归约器状态。 +- 取消、worker 死亡、工具失败、会话关闭和插件 dispose 释放归约器状态;回放仅发出持久的 pending/final 卡片对。 +- 单元测试、工作流集成测试、ACP 集成测试、快照、类型检查、覆盖率、doc-sync、module-graph、构建和 hygiene 门禁全部通过。 + +## 风险 + +此变更向工具 seam 添加了一个公开的实时进度方法和事件,因此实现方必须精确维护 active/terminal 边界,并在观察者看到快照之前将其分离。pre-start 捕获器可能短暂观察到无关的工作流 run,因此它仅按 run id 持有紧凑的候选状态,并在 `start()` 返回后立即丢弃所有不匹配的候选。一个工作流可能发出大量进度变更;有界归约器避免了 transcript 增长,但在关联之后仍会为每个有意义的事件发送一次 UI 更新。如果实测客户端需要合并更新,必须通过带默认值的、经过校验的桥接配置实现,而非硬编码的节流。瞬态进度在回放时有意消失,因此最终的工具结果仍是唯一持久的工作流卡片内容。 diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml new file mode 100644 index 0000000000..26c31b7471 --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-api-extractor-reports.md: 0f3f736ba662fd6366eb8d7f26887fb319b2b563 +2026-06-11-api-extractor-reports.zh.md: 3c472d64bc96c7fffa784c091f8a7a70bb0beb56 diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md index a6f3bfb14c..0f3f736ba6 100644 --- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md @@ -1,5 +1,7 @@ # RFC: API extractor reports +English | [中文](2026-06-11-api-extractor-reports.zh.md) + Status: proposed > Split out from the original "Doc-sync and API reports" RFC (2026-06-11). Parts 1-2 (doc-block typechecking, event-taxonomy verification) shipped — see [doc-sync enforcement](../../implemented/process/2026-06-11-doc-sync-enforcement.md). This is the deferred part 3, kept as a standalone proposal. diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md new file mode 100644 index 0000000000..3c472d64bc --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md @@ -0,0 +1,32 @@ +# RFC:API extractor 报告 + +[English](2026-06-11-api-extractor-reports.md) | 中文 + +Status: proposed + +> 从最初的「Doc-sync 与 API 报告」RFC(2026-06-11)中拆出。第 1、2 部分(文档块类型检查、事件分类体系校验)已交付——见 [doc-sync 强制](../../implemented/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 + +## 问题 + +公开 API 的变更是不可见的:没有任何机制让「这个 commit 改变了公开接口」成为一个显式、可评审的事实。评审者阅读 diff 时可能遗漏一个导出类型新增了字段或方法签名发生了变化。 + +## 提案 + +使用 api-extractor(或 `tsc --emitDeclarationOnly` 加一份归一化的公开接口导出)为每个包(package)生成一份签入仓库的 `etc/<pkg>.api.md`;如果重新生成的结果与签入版本不同,CI 失败。这样每一次公开 API 变更都会变成评审者(或评审 agent)必须看到的一行 diff。 + +## 曾考虑的替代方案 + +**`tsc --emitDeclarationOnly` 加一份归一化的公开接口导出**:如果 api-extractor 被证明过重,这是更轻量的机制;两者都满足本提案所需的「签入仓库、可 diff」的报告形态。 + +## 验收标准 + +- 每个包有一份签入仓库的 `etc/<pkg>.api.md`;重新生成结果与已提交报告不同时 CI 失败。 +- 公开 API 变更(新增导出、字段放宽、签名变化)在评审中以报告 diff 行的形式可见。 + +## 风险 + +该依赖重且难伺候——这正是它被推迟的原因——且报告格式会随编译器升级而变动,在各包尚未发布的阶段增加了一个收益甚微的维护面。 + +## 推迟原因 + +在 doc-sync 落地时被推迟:对于评审者已经能看到源码 diff 的内部 monorepo 而言价值有限,且依赖重、难伺候。如果这些包将来对外发布,届时一份稳定、可 diff 的公开接口报告才值得其维护成本。 diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml new file mode 100644 index 0000000000..8aa3f297cd --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-architectural-conformance.md: 40858d049af2df1928e27280238d0b198a5202f7 +2026-06-11-architectural-conformance.zh.md: d61751210ef78b26e05a05efae4d5abccbfd2e5c diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md index e0d16b455d..40858d049a 100644 --- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md @@ -1,5 +1,7 @@ # RFC: Architectural conformance — dependency rules and the adapter kit +English | [中文](2026-06-11-architectural-conformance.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md new file mode 100644 index 0000000000..d61751210e --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md @@ -0,0 +1,36 @@ +# RFC:架构一致性——依赖规则与适配器套件 + +[English](2026-06-11-architectural-conformance.md) | 中文 + +Status: proposed + +## 问题 + +两项架构保证目前仅存在于行文中:(1)任何包不得依赖具体的 loop 包([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确地遵循 chunk 协议。两者都应当机械化([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 + +## 提案 + +**dependency-cruiser** 配合以下规则: + +- `packages/*`(agent-loop 自身的测试和 examples/ 除外)禁止导入 `@deepseek-ai/dsh-agent-loop`。 +- 禁止跨包深层导入(`@deepseek-ai/dsh-*/src/...` 路径)——只允许使用公开入口点。 +- packages/ 内禁止任何导入循环。 +- `vendor/*` 禁止从 `packages/*` 导入。 +- 分层:dsh-llm 不导入其他 dsh 包;dsh-session 只导入 dsh-llm;以此类推(即 packages/README.md 中的依赖表,强制执行)。 + +**适配器一致性套件**位于 dsh-llm(`@deepseek-ai/dsh-llm/conformance`):一个可复用的 vitest 套件,以适配器工厂为参数,断言 chunk 协议契约——每个 block 的 index 单调递增、`block-end` 之后该 index 不再有 delta、恰好一个 `finish`、usage 至多出现一次、每个 `tool-call-delta` 携带 call id、abort 被及时响应。当前对 mock 运行;DeepSeek V4 适配器从第一天起继承该套件。可选地提供一个 dev 模式的 `strictAdapter()` 包装层,在 debug flag 下于运行时强制执行相同约束(与 [dev 模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)配对)。 + +## 计划 + +先落地 dependency-cruiser 配置与 CI 步骤(约一小时工作量,永久保证);一致性套件随其首个消费方测试(针对 MockAdapter)一起落地,并作为 V4 适配器阶段的前置条件。 + +## 验收标准 + +- dependency-cruiser 在 CI 中运行上述规则族;违规导入导致构建失败。 +- 一致性套件对 mock 适配器和两个正式适配器运行通过;新适配器包通过调用该套件并传入自己的工厂即可继承测试。 + +## 风险 + +随着包的增加需要维护 dep-cruiser 规则——应保持规则基于模式(`dsh-*`)而非逐一枚举。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml new file mode 100644 index 0000000000..5ac9f62fee --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-supply-chain-and-vendor-drift.md: 306e185e9175e3e7af24455cf95167f54b3d1c17 +2026-06-11-supply-chain-and-vendor-drift.zh.md: 1aeb0a8eff3f335bc87c05742acc4502a19bf3c5 diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md index e787133026..306e185e91 100644 --- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md @@ -1,5 +1,7 @@ # RFC: Supply chain checks and vendor drift verification +English | [中文](2026-06-11-supply-chain-and-vendor-drift.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md new file mode 100644 index 0000000000..1aeb0a8eff --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md @@ -0,0 +1,35 @@ +# RFC:供应链检查与 vendor 漂移校验 + +[English](2026-06-11-supply-chain-and-vendor-drift.md) | 中文 + +Status: proposed + +## 问题 + +vendor manifest([vendor 化决策](../../implemented/process/2026-06-11-vendor-cordis-as-source.md))在提交时只做*正向*强制(vendor 代码变更 ⇒ manifest 更新),但没有任何机制校验 manifest 的*声明*:即 vendor/ 确实等于「上游指定 SHA 的代码 + 日志中记录的修改」。此外,少量真正的 npm 依赖也没有安全公告监控或更新节奏。 + +## 提案 + +1. **Vendor 漂移检查**(夜间 CI):以 manifest 中的 SHA 浅克隆上游仓库,复制对应 package 的源码,与 `vendor/*/src` 做 diff。除非 diff 与日志中的本地修改一致(每项修改保存为一个入库的 patch 文件,使日志条目成为可校验的产物而非纯文字),否则 job 失败。 +2. **依赖安全公告**:对 lockfile 运行 osv-scanner(或 `pnpm audit`),按计划调度 + 在涉及 lockfile 的 PR 上触发。 +3. **许可证清单**:一个脚本断言每个 vendor 化的 package 都携带 LICENSE 文件,且 package.json 的 `license` 字段与 vendor/README.md 中的清单一致(我们混合了 vendor 化的 MIT 与自有的 BSD-3)。作为 CI 步骤运行。 +4. **Renovate**(或一个定时 agent 任务)以小 PR 提议 npm 依赖更新,这些 PR 走完整门禁套件;vendor 化的 package 排除在外(它们的更新遵循 manifest 同步流程,理想情况下作为半自动化的 agent 工作流:拉取上游、重新应用 patch、运行门禁、打开 PR 并更新 manifest 表格)。 + +## 计划 + +3 最简单,先做。1 需要 CI 能通过网络访问上游仓库(私有镜像,需要 token),并将现有两项已记录的修改转为 patch 文件。2 和 4 属于配置工作。 + +## 曾考虑的替代方案 + +- **用 `pnpm audit` 代替 osv-scanner**:两者都满足安全公告扫描的需求;具体选择推迟到实现阶段决定。 +- **用定时 agent 任务代替 Renovate**:在「以小 PR 提议更新并走完整门禁」这件事上效果等价;vendor 化的 package 无论哪种方案都排除在外(它们的更新遵循 manifest 同步流程)。 + +## 验收标准 + +- 许可证清单脚本在 CI 中运行,缺少 LICENSE 或 `license` 字段与 `vendor/README.md` 清单矛盾时失败。 +- 夜间漂移 job 从 manifest SHA 加入库 patch 文件重建 `vendor/`,出现任何无法解释的 diff 时失败。 +- 安全公告扫描按计划对 lockfile 运行,并在涉及 lockfile 的 PR 上运行。 + +## 风险 + +上游仓库是私有镜像;CI 凭证与可用性是漂移检查的主要阻力。如果受阻,改为本地定时 agent 任务而非 CI 运行。 diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml new file mode 100644 index 0000000000..f08e3c5dff --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-discover-package-inventory.md: 22b3e9acbe4dad8ef829d0dd30415c516031d66b +2026-06-20-discover-package-inventory.zh.md: 8b62d42d2d514f60016690ba55d5ce77a50b3aff diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index c4ee6161bd..22b3e9acbe 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -1,5 +1,7 @@ # RFC: Discover package inventories instead of maintaining static lists +English | [中文](2026-06-20-discover-package-inventory.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md new file mode 100644 index 0000000000..8b62d42d2d --- /dev/null +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md @@ -0,0 +1,36 @@ +# RFC:通过发现机制获取包清单,取代静态列表维护 + +[English](2026-06-20-discover-package-inventory.md) | 中文 + +Status: proposed + +## 问题 + +包(package)与门禁的清单在 TypeScript project references、package 文档、CI 行文、Knip 覆盖项以及快照场景元数据中反复出现。其中大部分只是重述包布局、manifest 数据、聚合命令内容或 fixture(测试前置数据)文件。每新增一个包或场景,都会产生本可避免的同步点。 + +[包层级结构](../../implemented/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages/<group>/<pkg>` 布局推导清单,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单——主要是 `tsconfig.build.json` 的 project `references`,TypeScript 要求它是一个显式数组(没有通配符形式)。 + +静态列表在编码策略时是合理的;当它们只是重复 `package.json`、workspace glob 或包层级结构中已有的 manifest 数据或布局事实时,就是无谓的摩擦。 + +## 提案 + +让剩余的包/门禁清单可被发现。一个唯一的权威来源——`packages/<group>/<pkg>` 层级结构加上 package manifest——应当驱动 `tsconfig.build.json` 的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写入产物,`--check` 模式在 `hygiene`/`doc-sync` 中检测已提交副本是否陈旧)。模块图生成器已经在读取 package manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令而非重述第二份清单。 + +层级结构不需要编码一个包的所有信息,但应当编码宽泛的维护策略:core/product 包、集成包、能力 seam 包与 support/test/example 包不应在脚本能区分它们之前先要求一份手工维护的例外清单。 + +有两项被编目的内容根本不需要生成器:把 e2e 入口 glob 折入 knip 的默认 stanza 即可直接删除各包的重述;`childSessions` 可以从每个场景的 fixture 目录发现,让场景表只声明策略(`recorded`、`hasModelTurn`、`comparesLog`)。而即便这些策略字段,今天也在追踪可从 fixture 推导的事实(`comparesLog` ⟺ 已提交的日志在表头行之后有内容;`recorded` ⟺ `hasModelTurn` 且没有 `replay.override.json` 兄弟文件),因此每个新场景类别都在不断添加 fixture 目录已经能回答的开关。 + +## 验收标准 + +- `tsconfig.build.json` 的 project `references` 由层级结构生成(生成器输出它们;`--check` 门禁在已提交副本陈旧时失败),而非手工维护。 +- 新增一个包不需要为任何门禁编辑静态包列表。 +- 文档描述真源,而非重复生成的清单。 +- CI 调用聚合命令,由这些命令自行管理其子门禁列表。 +- `knip.json` 仅在编码真实信息(额外入口文件、被忽略的依赖)时才携带 per-package 覆盖项,绝不重述默认 stanza。 +- 快照场景只声明策略,不声明可从其 fixture 目录发现的事实。 + +## 风险 + +发现脚本可能变得过于精巧。实现应保持朴素:读取 manifest、按显式字段过滤、打印解析后的列表、出错时大声报错。收益在于消除手工清单漂移,而非发明一套构建系统。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml new file mode 100644 index 0000000000..db8dc2d650 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-unify-agent-and-session-id.md: 3a6daa411673003eb1c3017e7a717ae4bf98b735 +2026-06-20-unify-agent-and-session-id.zh.md: c931831c028e3147bfb84ed4fdeefe83037e92f4 diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index d94ce11f5c..3a6daa4116 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -1,5 +1,7 @@ # RFC: Unify the agent id and the session id +English | [中文](2026-06-20-unify-agent-and-session-id.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md new file mode 100644 index 0000000000..c931831c02 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -0,0 +1,42 @@ +# RFC:统一 agent id 与 session id + +[English](2026-06-20-unify-agent-and-session-id.md) | 中文 + +Status: proposed + +## 问题 + +agent 工厂为每个活跃的 agent/会话对维护两个 id:`agentId`(`AgentRegistry` 的路由句柄)和 `sessionId`(事件溯源与持久化日志的身份标识)。`CreateAgentOptions` 接收两者;`ResumeAgentOptions` 接收 `agentId` 加 `resumeSessionId`;进程内 subagent 铸造两个独立的 UUID,尽管血缘关系另行记录。 + +ACP(Agent Client Protocol)已经对两个身份使用同一个值。二者在配置创建的 agent、恢复的会话和进程内子 agent 中才出现分歧,但没有任何生产路径会将一个活跃 agent 重新关联到多个会话,或让一个会话经过多个 agent id。Stdio 保留 `labelBySession` 仅仅是为了从会话事件中恢复 agent 标签,而钩子同时暴露两个值让使用者自行对齐。 + +[agent 作用域运行时](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)没有与身份相关的预留状态:创建和恢复使用同一个 `AgentCreationTransaction`,两个注册表条目使用相同的 final-entry 碰撞规则。分离的 id 并未复制活跃性、回滚或静默机制。统一后删除一个调用方提供的 id、每个进程内子 agent 的一个 UUID 以及剩余的翻译路径,而不改变事务生命周期;同时使活跃 agent 注册表强制执行后台任务所有权所使用的会话身份。 + +`Session` 另外同时暴露 `Session.id` 和 `Session.header.id`,尽管构造时要求二者一致。持久化边界必须校验这一重复值,消费方必须在同一事实的两个归属位置之间做选择。 + +## 提案 + +对 agent 注册表条目和 `session.header.id` 使用同一个 id。`CreateAgentOptions` 为两个最终条目接受一个身份标识;恢复操作以被恢复的 session id 注册 agent;subagent 创建铸造一个合并后的 id;`Session` 只保留一个身份归属位置。保留当前的事务、final-entry 碰撞检查、exact-entry 摘除、回滚与静默机制;仅移除唯一职责是在两个 id 之间做翻译的 map 和字段。 + +配置驱动的路径必须先确定其恢复还是创建的策略。目前它使用一个稳定的 agent 标签加一个带 UUID 后缀的新 session id,以避免在下次运行时与已有的持久化日志碰撞。统一后它必须明确选择:恢复一个固定 id、铸造一个新的合并 id,或将该策略暴露出来;实现不得默默做出选择。 + +`agent/created` 和 `agent/disposed` 不在本提案范围内。它们是发布生命周期事件而非身份别名;移除它们需要单独的生产方-消费方审计与决策。 + +## 曾考虑的替代方案 + +**保留分离的路由身份与日志身份。** 一个稳定的配置 agent 标签搭配一个新的对话,是这种区分的真实用途。如果确实需要该显示或路由身份,则应否决本提案,转而显式强制 session id 唯一性,而不是将翻译隐藏在另一个 map 中。 + +## 验收标准 + +- agent 创建/恢复与 subagent 创建只携带一个身份标识;`Session` 将其存储在一个位置。 +- 创建事务保留 final-entry 碰撞、exact-entry 摘除、回滚与静默保证,且不依赖与身份相关的生命周期状态。 +- ACP、stdio、钩子、bash 所有权、持久化与血缘关系无需 agent/session id 翻译。 +- 配置驱动的恢复还是创建策略是显式的,并在持久化重启场景中得到覆盖。 +- `agent/created` 和 `agent/disposed` 仅在单独的生产方-消费方审计之后才变更。 +- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 + +## 风险 + +统一后将无法再拥有一个跨多个会话日志的稳定 actor 身份,包括未来可能的交接或 fork(保留 actor 但更换会话)。重新引入该设计需要一个新的显式 actor 身份。统一还使一个持久化的、可能由客户端选择的 session id 成为注册表句柄,并改变每个创建/恢复调用点和 fixture(测试前置数据)。 + +配置重启策略是阻塞性的设计决策:固定的合并 id 可能与其已有日志碰撞,而每次运行生成新 id 则放弃了稳定的配置标签。如果确实需要独立的 actor 身份或稳定标签/新会话的配对,则应否决本提案,保留分离的 id 并加上显式的唯一性守卫。 diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml new file mode 100644 index 0000000000..1d5522f5b3 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-prune-dead-core-spine-surface.md: c46fe464e8627dcfc39a1d3fbb38a9cbd84269cf +2026-07-04-prune-dead-core-spine-surface.zh.md: 86830904ef0d1262d4cc132fb8d4b6a50e033e1d diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index cca7c57b34..c46fe464e8 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -1,5 +1,7 @@ # RFC: Prune dead public and result surface +English | [中文](2026-07-04-prune-dead-core-spine-surface.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md new file mode 100644 index 0000000000..86830904ef --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -0,0 +1,62 @@ +# RFC:裁剪无用的公开接口与结果面 + +Status: proposed + +[English](2026-07-04-prune-dead-core-spine-surface.md) | 中文 + +## 问题 + +若干包根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入内部实现,要么是因为某个类型预设了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的目录、文档和回归矩阵,却没有支撑任何已交付的路径。 + +生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、package README 和 RFC 行文是发布的证据,但不是固定的调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此被编目的服务方法和返回形状是真正的动态产品面。下表因此区分了「没有固定的仓库内调用者」与「不可达」:涉及编目词汇的行有意收缩模型编写的 mount 所能发现和调用的内容,而包根的实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: + +| 接口面 | 生产证据 | 简化方式 | +| --- | --- | --- | +| `SurfaceManager.invalidate()` | 仅其单元测试调用;seeding 在惰性创建的 manager 存在之前就已完成,且会话从不替换其日志引用。 | 删除该方法及其不可能触发的整体替换契约。 | +| `ToolExecutionResult.callId` | 每个钩子已经接收不可变的 `ToolExecution`;循环和 ACP(Agent Client Protocol)通过 call/session 事件关联。没有消费方读取这个重复的结果字段。 | 移除该字段、复制/不匹配守卫,以及证明该重复不会不一致的测试。 | +| `ReactLoopAgent` 根导出 | 包外的具名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 返回/接口类型为 `Agent`,将具体循环类设为包内部;保留有意为之的同步纯配置 `AgentLoop.create()` 路径。 | +| `workflow-workerthread` 的 protocol/runtime/session 再导出与具名 `WorkerWorkflowEngine` | 所有包名消费方使用默认引擎;workflow RFC 已将 worker 协议格式定义为私有。 | 保留默认插件类/配置契约;移除重复的具名类导出,将协议模块设为源码私有。 | +| `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇设为源码私有。 | +| ACP 的 translation/presenter 根导出 | `agentOptions`、`streamSessionEventUpdate`、`todosToPlan`、`ToolPresenter`、`nullToolPresenter` 和 `TerminalRendering` 仅有同文件或 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 translation/presentation 辅助函数设为源码私有,在包内测试。 | +| `providerWording` 和 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;仅 balanced-prefix 辅助函数有一个同包白盒测试。 | 设为源码私有,通过 provider 行为测试。 | +| `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 和 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/释放辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 设为源码私有;通过 spawn 和释放来测试。 | +| `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 和 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 设为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 | +| `LlmError.status` 与 replay status | 适配器/replay 填充它,但生产分支基于稳定的 error code/message,从不读取原始 status。 | 移除未读字段和 replay 管道,同时保留错误分类。 | +| `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 | +| `compactRegion` 的独立 `session` 参数 | 固定调用者传入的对象与 `agent.session` 已经是同一个;模型可见的 mount API 也能调用该方法,但接受两个身份允许挂载的插件提供不一致的配对。 | 保留手动区域 seam,同时有意将其收窄为以 `agent.session` 为唯一真源。 | +| `CompactionResult.startSeq`、`summarySeq`、`endSeq` 和 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有摘要和事件标识。 | 移除四个结果回显,同时保留两个共享的 transcript(文本记录)渲染器。 | +| `BasicCompactService` 的 estimation/summarization 可见性 | 没有包外生产调用者调用这五个方法;已实现的 RFC 仅将 `estimateContentTokens()` 和 `summarize()` 列为子类钩子。 | 将这两个方法设为 `protected`,将三个仅用于编排的估算器设为 private。 | +| `CodeLogEntry.source`/`level` 和 `RunCodeMeta.dispatches` | 所有生产消费方将日志映射为文本;没有 presenter/模型路径读取其他字段或持久化的 dispatch 计数。 | 将 code-runtime 日志改为字符串(或纯文本条目),移除 result-meta dispatch 管道;保留用于生成确定性 dispatch id 的本地计数器。 | +| `ToolNotFoundError.toolName`、`SystemPrompt.config` 和 `BashTask.command` | 每个存储的公开值都没有生产读取者。 | 移除未读字段,同时保留错误消息、已解析的配置行为和任务生命周期。 | +| 后端包根实现辅助函数 | 下方精确清单仅通过相对同包导入调用。生产命名空间导入挂载的是保留的插件契约,不读取这些属性;具名根消费方是测试。 | 保留每个适配器/提供方/服务及其配置/错误契约;停止在包根导出所列辅助函数/常量。 | +| 消费方包根实现辅助函数 | 下方精确清单仅有同包生产调用者。生产命名空间导入挂载插件契约,不读取辅助属性;具名根消费方是测试。 | 保留插件契约和稳定错误码;将测试移至包内模块或公开行为,停止在包根导出所列辅助函数。 | + +### 分组辅助导出清单 + +- `dsh-llm-deepseek`:`httpErrorCode`、`serializeMessages`、`serializeRequest`、`DONE`、`parseSse`、`mapFinishReason`、`mapUsage` 和 `translate`;`dsh-llm-pi-ai`:`buildModel`、`mapStopReason`、`mapUsage`、`toPiContext` 和 `toStreamChunks`。 +- `dsh-bash-local`:`DEFAULT_GRACE_MS`、`ENV_OVERRIDES`、`killGroup`、`OutputCollector` 和 `runBash`;`dsh-bash-sandbox`:`shellQuote`、`classifyDenial` 和 `classifyRunnerFailure`;`dsh-sandbox-local`:`bwrapProfileArgs`、`landlockProfileArgs` 和 `seatbeltProfileArgs`。公开的可变测试注入字段及其类型不在本提案范围内。 +- `dsh-fs-local`:`applyLiteralEdit`、`listDirectory`、`probe`、`readForEdit`、`readTextForDiff`、`readWholeText`、`resolveLocalTarget`、`restoreLineEndings`、`streamWholeText` 和 `writeFileAtomic`。 +- `dsh-web-fetch-local`:`classifyContentType`、`decoderForCharset`、`isSameOrigin`、`parseCharset` 和 `validateFetchUrl`;`dsh-web-search-exa`:`mapExaResponse` 和 `mapExaResult`;`dsh-web-search-deepseek`:`citationSnippets` 和 `mapAnthropicResponse`;`dsh-web-search-perplexity`:`mapPerplexityResponse` 和 `mapPerplexityResult`。 +- `dsh-tool-fs`:`READ_LIMIT`、`STREAM_MIN_SIZE`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`DIFF_CONTEXT`、`applyReadTool`、`parseReadArgs`、`applyWriteTool`、`formatWriteOutput`、`parseWriteArgs`、`applyEditTool`、`formatEditOutput`、`parseEditArgs`、`buildWindow`、`formatReadOutput`、`computeHunkDiffs` 和 `diffsFromMeta`。 +- `dsh-tool-web`:`WEB_SEARCH_MAX_RESULTS`、`applyWebSearchTool`、`formatSearchOutput`、`parseSearchArgs`、`presentSearchCall`、`applyWebFetchTool`、`formatFetchOutput`、`parseFetchArgs`、`presentFetchCall`、`renderBody` 和 `htmlToMarkdown`;`dsh-timeout-policy`:`toolTimeoutResult`;`dsh-compact-basic`:`resolveConfig`;`dsh-tool-bash`:`renderResult`。 + +## 提案 + +以一次有界的、协调的公开接口面清理,移除或降级上述每一行。更新 package README、JSDoc、生成的 API/事件目录、type-equiv 记录、必要时的 exports map 以及测试,使测试通过所属的公开 seam 来验证行为,而非保留仅为测试而存在的入口点。不折叠任何能力 seam、LLM(大语言模型)适配器、持久化后端或生命周期静默契约。 + +## 曾考虑的替代方案 + +**保留测试便利函数和自包含结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更符合人体工学,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;今天它们让每一处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小契约,其所有权和失败语义已知。 + +**为模型编写的 mount 保留所有编目成员。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务面,而非无限期保留重复字段或不一致的参数对;上述每一项编目收缩都移除了在同一次执行、agent(智能体)或结果上其他位置已可获得的事实,并在同一个变更中更新 API 参考。 + +## 验收标准 + +- 精确符号搜索显示被移除的接口面不出现在本 RFC 和任何已实现 RFC 修正案之外。 +- 本 RFC 列出的每一项接口面均已按指定方式移除或降级;清单之外有意保留的扩展/测试契约不受影响。 +- 工具执行、压缩(compaction)、两个 LLM 适配器、两个持久化后端、工作流隔离以及 agent 创建/恢复保持其已交付行为。 +- 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建和 hygiene 全部通过。 + +## 风险 + +大多数移除在编译时可见但运行时无影响。压缩参数清理有意禁止 session/context 不匹配,同时保留手动区域 seam。外部预发布嵌入者和现有模型编写的 mount 可能导入更少的辅助函数、传入更少的参数或接收更窄的结果形状;这是有意的产品接口面收缩,而非仅仅是生成目录的清理。仓库尚未发布,因此承载不受支持的接口面才是更大的基础成本。 diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml new file mode 100644 index 0000000000..f7a882d65c --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-simplify-session-log-representation.md: 52720231d6e4cbe0cbb412332cd016ba63f83569 +2026-07-12-simplify-session-log-representation.zh.md: 468f9a565177089c8d49c06e8d490ab56980054a diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md index 715ce93924..52720231d6 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md @@ -1,5 +1,7 @@ # RFC: Simplify session-log representation +English | [中文](2026-07-12-simplify-session-log-representation.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md new file mode 100644 index 0000000000..468f9a5651 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md @@ -0,0 +1,38 @@ +# RFC:简化会话日志表示 + +[English](2026-07-12-simplify-session-log-representation.md) | 中文 + +Status: proposed + +## 问题 + +会话日志维护着两种表示,其机制开销超出了消费方的实际需求:伪链表 surface 与自定义请求头增量编码。 + +`SurfaceManager` 将同一顺序存储在数组、seq 映射和可变的 `prev`/`next` 链接三处。生产代码从不读取 `prev`;压缩(compaction)唯一一次读取 `next` 是取数组位置的后继。替换操作已经使用 `indexOf`,因此链接并未使其主要操作达到常数时间。一个 seq 数组加线性替换查找具有相同的渐近替换开销,且只有一种表示需要校验。 + +请求头子系统实现了自定义的 system/tool 增量编解码器与传输决策层,尽管其契约声明增量只是编码优化而非可重建性要求。在每个 agent loop 实例边界保留 initial/resume 完整快照,然后在该实例的组装头发生变化时写入一条规范的完整 `request/header`,即可保留回放能力,同时删除 `SystemDelta`、`ToolsDelta`、往返 fallback 以及持久化的 `request/header-delta` 变体。编解码器专用词汇随编解码器一起消失,并非因为其各分支本身无效。 + +本提案有意保留 append 与 replacement 的 `sourceEventSeqs`、崩溃恢复溯源,以及所有 `SessionStartSource` 变体:已实施的 RFC 赋予了这些字段审计/拦截角色,零当前读者不足以推翻这一点。 + +## 提案 + +将 `SurfaceManager.nodes` 改为事件序列号的 `readonly number[]`,移除公开的 `SurfaceNode` 形状。保留内部的 replace-generation 信号;更新工具配对平衡与压缩调用方,使其通过数组值/索引获取前驱、后继与替换范围,移除节点链接与 seq-to-node 映射。将锚点后的请求头增量替换为规范的完整变更头快照,移除增量编解码器/事件/测试;initial 与 resume 锚点即使折叠后的头未变也仍为完整快照。 + +修订会话 surface 与可重建请求的 RFC 中描述已移除编码的部分。更新事件类型/不变式、请求日志/回放、持久化 fixture(测试前置数据)、生成的 catalog、包文档与快照。将编解码器专用的 `fallback` 原因替换为显式的 `change` 原因(用于锚点后的完整快照),以区别于保留的 `initial` 与 `resume` 锚点。 + +`SESSION_FORMAT_VERSION` 有意保持为 `0`,因此包含 `request/header-delta` 的旧 v0 日志在增量折叠被删除后,若不做处理将通过版本检查并静默丢失头变更。seed/load 校验必须在格式边界处拒绝该遗留事件并快速失败;不添加兼容折叠或迁移。 + +## 曾考虑的替代方案 + +**保留链表节点与紧凑增量以备未来规模。** 链接可能有助于未来的游标 API,增量在大型工具 schema 仅有少量变化时能减小日志体积。但没有已发布的游标使用这些链接,而完整快照以磁盘空间换取显著更简单的正确性。如果头部体积确实成为问题,可以基于真实 trace 设计压缩方案或经过度量的规范增量方案。 + +## 验收标准 + +- `SurfaceManager.nodes` 是一个有序 seq 数组,没有 `SurfaceNode`、链接字段或 seq-to-node 映射;增量追加处理与内部 replace-generation 信号保留。 +- 回放完整变更头快照能重建出完全相同的请求;不再存在任何 header-delta 事件/类型/编解码器。 +- 包含遗留 `request/header-delta` 的 v0 seed 或持久化日志在回放前被拒绝,JSONL 与 SQLite 加载路径均有覆盖。 +- 新形状的 v0 JSONL/SQLite 回放、溯源、崩溃恢复、压缩、快照、不变式、类型检查、覆盖率、doc-sync、构建与 hygiene 全部通过。 + +## 风险 + +完整头会增加日志体积,线性替换查找在非常大的 surface 上可能更慢。替换操作目前已经是线性的,因为实现调用了 `indexOf`;只有在真实 trace 表明更简单的数组成为瓶颈时才应添加基准测试。由于格式版本保持为 `0`,如果遗漏了对遗留事件的显式拒绝,后果将是静默数据损坏而非类型错误;因此快速失败的加载测试是本提案的组成部分,而非可选的清理工作。 diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml new file mode 100644 index 0000000000..3e3afa30d0 --- /dev/null +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-deterministic-and-stress-testing.md: e4ed7043d1880b55dd7d77b3e09a81dd58739a70 +2026-06-11-deterministic-and-stress-testing.zh.md: d933c1dda329b95573b7a5a3fbe3a61ef94390cb diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md index 2a30969ba4..e4ed7043d1 100644 --- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md @@ -1,5 +1,7 @@ # RFC: Deterministic tests, the replay invariant fixture, and race stress +English | [中文](2026-06-11-deterministic-and-stress-testing.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md new file mode 100644 index 0000000000..d933c1dda3 --- /dev/null +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md @@ -0,0 +1,33 @@ +# RFC:确定性测试、回放不变式 fixture 与竞态压力测试 + +[English](2026-06-11-deterministic-and-stress-testing.md) | 中文 + +Status: proposed + +## 问题 + +若干 agent loop(智能体循环)测试通过 `setTimeout(30)` 睡眠来同步——这是一笔不稳定性债务,浪费 agent 重试周期,还可能掩盖排序 bug。另一方面,我们的核心架构承诺(任何会话日志回放后都能得到完全相同的派生历史)目前只在两个测试中断言,但在*所有地方*断言的成本很低。此外,inbox 唤醒竞态只被手动验证过一次,没有任何东西持续地重新验证它。 + +## 提案 + +三项措施: + +1. **测试中禁止挂钟睡眠。** 将 `setTimeout(N)` 等待替换为事件驱动等待(现有的 `waitForIdle` 模式,扩展为 `waitForStatus`、`waitForEvent(n)`),或在需要测试时间本身时使用 vitest fake timers。通过 lint 规则强制:禁止在 `packages/*/tests` 中使用 `setTimeout`,白名单辅助模块除外。 +2. **通用回放 fixture(测试前置数据)。** 一个共享的测试辅助函数包装 agent loop harness,使得每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被检查数百次(覆盖套件产生的所有场景),而非仅两次。 +3. **夜间竞态压力测试。** 一个 CI job 以 `vitest --repeat=200`(加 `--shuffle`)运行 agent-loop 和 inbox 套件,以暴露调度依赖的失败;发现的任何不稳定测试都作为 bug 修复,绝不靠重试掩盖。 + +## 计划 + +措施 1 和 2 一起落地(它们改动相同的辅助模块);在套件消除所有睡眠之后再添加夜间 job,使重复运行足够快。 + +## 验收标准 + +- `packages/*/tests` 中不再有 `setTimeout`(白名单辅助模块除外),由 lint 规则强制。 +- 共享 harness 对每个测试的会话日志进行回放,将其注入全新的 `Session` 并自动断言 `deriveMessages()` 相等,覆盖整个套件。 +- 夜间 job 以 `--repeat` 和 `--shuffle` 运行 agent-loop 和 inbox 套件;发现的不稳定测试作为 bug 分诊处理,绝不靠重试掩盖。 + +## 风险 + +Fake timers 与 agent loop 中的 Promise 调度存在微妙交互——优先使用事件驱动等待;仅在测试 timer 服务行为本身时才使用 fake timers。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml new file mode 100644 index 0000000000..3f6c6157fa --- /dev/null +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-mutation-testing.md: 344263d1c91a5e6c83320f367bf76ed6f7ef5a49 +2026-06-11-mutation-testing.zh.md: aa89b3335a143b6f34858bdc2f3344a6a7d758d9 diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md index 8c68ccf09f..344263d1c9 100644 --- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md @@ -1,5 +1,7 @@ # RFC: Mutation testing as the coverage counterweight +English | [中文](2026-06-11-mutation-testing.zh.md) + Status: proposed ## Problem diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md new file mode 100644 index 0000000000..aa89b3335a --- /dev/null +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md @@ -0,0 +1,36 @@ +# RFC:变异测试作为覆盖率的制衡 + +[English](2026-06-11-mutation-testing.md) | 中文 + +Status: proposed + +## 问题 + +逐文件 100% 覆盖率门禁(见[质量门禁决策](../../implemented/process/2026-06-11-quality-gates.md))证明的是每一行都在测试中*被执行*了,而非任何断言会在该行出错时有所察觉。在 agent 编写测试的场景下,覆盖率压力可能催生「执行但无断言」的测试。变异测试衡量的正是覆盖率无法衡量的:测试套件是否能*杀死*被刻意注入的缺陷。 + +## 提案 + +在 `packages/*/src` 上运行 Stryker(`@stryker-mutator/vitest-runner`): + +- **PR 粒度的增量运行**(仅变更文件),作为 CI job:调优后足够快,可以作为合并门禁。 +- **每夜全量运行**,跟踪变异分数;先记录基线,再将阈值设为观测到的基线值并只升不降(与覆盖率策略一致:阈值只收紧)。 +- 存活的变异体是待办工作项:agent 选取一个存活体、编写杀死它的测试、循环往复——一个形态良好的自主循环。 +- 等价变异体(可证明不改变行为的)加带理由的排除注解,与 `/* v8 ignore */` 策略对称。 + +## 计划 + +1. 添加 Stryker 配置,范围限定在一个包(llm:最小、最具算法性),测量运行时间。 +2. 扩展到所有包;在配置中记录基线分数。 +3. 接入每夜 job;当运行时间可接受后,添加 PR 粒度的增量 job。 + +## 验收标准 + +- Stryker 配置在 `packages/*/src` 上以 vitest runner 运行;每夜 job 记录变异分数,且当分数低于记录的基线时,运行失败(阈值只升不降)。 +- PR 粒度的增量运行在运行时间可接受后作为合并门禁;或者明确保持仅每夜运行,并将该结论记录于此。 +- 等价变异体带有附理由的排除注解,与 `/* v8 ignore */` 策略对称。 + +## 风险 + +运行时间:变异测试开销大;逐文件 100% 覆盖率有所帮助(每个变异体至少会被执行到)。如果 PR 粒度的运行始终太慢,则保持仅每夜运行,依赖分数只升不降的机制。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml new file mode 100644 index 0000000000..3c2a50a714 --- /dev/null +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-11-immutable-public-surfaces.md: 68472d9817f0de22777314927f05f90949903723 +2026-06-11-immutable-public-surfaces.zh.md: e067b48d9a936133abdf149fbb9ff626c9411851 diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md index 3eedd92a2d..68472d9817 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md @@ -1,5 +1,7 @@ # RFC: Deep-readonly public surfaces +English | [中文](2026-06-11-immutable-public-surfaces.zh.md) + Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). ## Problem diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md new file mode 100644 index 0000000000..e067b48d9a --- /dev/null +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md @@ -0,0 +1,29 @@ +# RFC:深度只读的公开接口 + +[English](2026-06-11-immutable-public-surfaces.md) | 中文 + +Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). + +## 问题 + +被否决的提案针对的是一个所有权漏洞:仅靠 `readonly SessionEvent[]` 类型无法封堵该漏洞,因为数组元素在运行时仍然可变,一次类型断言或纯 JavaScript 就能改写嵌套的历史记录。最终实现的设计在 `Session` 中通过物化并深度冻结每个已接受的事件、返回冻结的数组快照来封堵该漏洞。进行中的 prompt waterfall(瀑布式事件)被有意保留为可变,因此不可变性是一条所有权边界,而非一条覆盖全局的类型规则。 + +## 提案 + +> **实际实现方式不同——见 Status 行与 [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)。** 下文的 `DeepReadonly<T>` 设计已被否决:它仅在编译期生效、对消费方噪音大、且可被 cast 绕过。`Session` 改为在每次组合中对已接受的事件和公开日志快照进行快照与深度冻结;`deriveMessages()` 返回分离的冻结投影;开发插件检查跨记录与跨 seam 的关系约束。 + +在类型层面将不可变性施加于「变异即腐败」的位置: + +- `SessionEvent` 数据在从会话**输出**时(`events`、`session/event` 监听器)变为 `DeepReadonly`;`append()` 仍接受普通可变输入。一个 `DeepReadonly<T>` 工具类型放入 dsh-llm,与 brand/never 辅助类型并列。 +- `deriveMessages()` 返回深度只读的消息;agent loop(智能体循环)在将可变请求交给 `agent/request` waterfall 之前先克隆一份(在 waterfall 中变异是被允许的——克隆使边界显式且廉价,每步仅一次)。 +- `PromptAssembly` 在其 waterfall 流程中保持可变(被允许),但注册表的内部 section 列表在每次组装时被克隆(已有此行为)。 + +## 计划 + +引入 `DeepReadonly`,翻转会话的读取路径,并修复消费方由此产生的编译错误。 + +## 风险 + +`DeepReadonly` 类型会在 waterfall 边界处产生噪音错误——因为变异在那里正是 API 的一部分。应将可变/只读边界严格限定在「已记录 vs 进行中」,并在会话 README 中加以说明。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml new file mode 100644 index 0000000000..ba7de6f475 --- /dev/null +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-providerless-example-base.md: be5122ec6665dcea15619f3cb4b3ed3a2fa03972 +2026-06-20-providerless-example-base.zh.md: 011636bbdfb782a5b6e4c03695cd0a4a428993d5 diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md index 0fd4ca27f7..be5122ec66 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md @@ -1,5 +1,7 @@ # RFC: Make the shared example base providerless +English | [中文](2026-06-20-providerless-example-base.zh.md) + Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-spine-demo` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. ## Problem diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md new file mode 100644 index 0000000000..011636bbdf --- /dev/null +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md @@ -0,0 +1,31 @@ +# RFC:使共享示例基础配置不依赖提供方 + +[English](2026-06-20-providerless-example-base.md) | 中文 + +Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-spine-demo` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. + +## 问题 + +示例曾有两个共享基础文件:`examples/base-core.yml` 不依赖任何模型提供方,而 `examples/base.yml` 在此核心之上加入了真实的 `llm-deepseek` 适配器。快照回放需要搭配 `llm-replay` 使用那个提供方无关的核心,因为在没有 key 的情况下加载真实适配器会抛错。常规演示则需要真实适配器。结果是命名倒挂:名为 `base.yml` 的文件并非所有示例的可复用基础,而真正的基础反而叫 `base-core.yml`。 + +这种拆分可以理解,但它让每次解释配置都变得更长。它还导致了别扭的测试搭建方式:keyless 冒烟测试需要携带一个假 API key 才能让适配器启动,尽管模型根本不会被调用。 + +## 提案 + +将提供方无关的核心重命名为 `examples/base.yml`,让适配器选择在每个具体示例中显式声明。编码与 ACP 真实配置添加一小段 `llm-deepseek` include 或本地块;快照配置添加 `llm-replay`。删除 `examples/base-core.yml`。 + +共享基础应当只包含提供方无关的服务与工具:`llm`、会话、系统提示词、工具、agent、不变式、bash 执行器与 bash 工具 schema。任何选择模型提供方的内容都属于叶子配置。 + +## 验收标准 + +- `examples/base.yml` 不依赖任何提供方。 +- `examples/base-core.yml` 已删除。 +- 真实演示配置显式添加 DeepSeek 适配器。 +- 快照回放配置引入同一个提供方无关的基础及其回放适配器。 +- [examples README](../../../../examples/README.md)、各示例的 README 与 RFC 引用不再解释"base = base-core 加适配器"。 + +## 放弃了什么 + +真实演示失去了一层便利:每个都必须显式引入适配器。对示例而言这是正确的默认值,因为适配器选择是可变部分,而提供方无关的接线才是共享的产品核心。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml new file mode 100644 index 0000000000..243b308c50 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-assembled-assistant-messages-only.md: 7605f286cf5914a127f5f8e2b77490648b42cc30 +2026-06-20-assembled-assistant-messages-only.zh.md: e282c5bd74fd3b854c85112d64de44a5470007bc diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md index 088fa8d25d..7605f286cf 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md @@ -1,5 +1,7 @@ # RFC: Persist assembled assistant messages, not stream chunks +English | [中文](2026-06-20-assembled-assistant-messages-only.zh.md) + Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md new file mode 100644 index 0000000000..e282c5bd74 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md @@ -0,0 +1,36 @@ +# RFC:只持久化已组装的 assistant 消息,不持久化流式分片 + +Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. + +[English](2026-06-20-assembled-assistant-messages-only.md) | 中文 + +## 问题 + +当前的规范会话日志会逐条持久化模型流出的每个 `assistant/chunk`。[会话持久化 RFC](../../implemented/architecture/2026-06-14-session-persistence.md) 选择这一方案是为了 token 级别的回放保真度和连续的 `seq`,但代价日益增长:JSONL fixture(测试前置数据)被大量微小的 delta 记录占据;快照场景通过分组 chunk 事件来回放模型;ACP 加载时需要从 chunk 重建先前的 assistant 输出;任何未来的日志读取方都必须区分持久的消息历史与 token 级别的追踪。 + +对于成功完成并组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复对话状态已经存在,无需 chunk;chunk 是实时渲染和确定性测试的产物,不是必需的对话历史。失败或中止的流则不同:部分 assistant 输出可能仅以 chunk 形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。 + +## 提案 + +停止在规范会话日志中存储 `assistant/chunk`。持久日志只保留 `assistant/message`、`tool/call`、`tool/result`、保留的 `usage`,以及轮次边界。实时 UI 仍可通过一个刻意设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从已记录的适配器产物派生,而不是把规范的用户会话当作 token 磁带。需要部分失败流输出的场景必须在回放 fixture 中记录该输出。 + +ACP 的 `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而不是模拟原始的 token 流。加载的 transcript(文本记录)不必重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并以有效的提供方历史恢复对话。 + +## 验收标准 + +- `SessionEventMap` 移除 `assistant/chunk`,或在需要过渡性实时事件时将其标记为不持久化。 +- [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)不再要求逐条存储每个流式分片。 +- `llm-replay` 与 ACP 快照使用显式的回放 fixture 格式或伴随文件来承载模型 chunk。 +- `session/load` 从 `assistant/message` 渲染已完成的 assistant 消息。 +- 存储的日志大幅缩小,且在没有 chunk 空洞的情况下保持 `seq` 连续。 +- 会话格式版本与已记录的 fixture 一并刷新;按预发布格式策略拒绝非当前版本的存储日志。 + +## 放弃了什么 + +规范的用户会话不再能重建旧轮次的精确 token 流。它还会丢失失败或中止流的部分 assistant 输出,除非有其他事件或 fixture 记录了它。对于当前的恢复、加载和快照契约而言,这是过大的信息损失。需要精确确定性流的测试应当自行拥有该 fixture,前提是生产会话日志为用户可见的恢复保留了足够的保真度。 + +## 相关 + +本 RFC 取代[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)中关于 chunk 持久化的决策,并影响 [ACP 快照测试](../../implemented/testing/2026-06-19-acp-snapshot-tests.md)——其当前的回放插件从 `assistant/chunk` 事件派生脚本。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml new file mode 100644 index 0000000000..9d083a9f72 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-drop-acp-session-load.md: 39f24d313db6083db45ff6fbc4a84f504e7714cb +2026-06-20-drop-acp-session-load.zh.md: 003c66636a75e21199f3c3ac600867cf9b73b87c diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md index 18cd6e981d..39f24d313d 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md @@ -1,5 +1,7 @@ # RFC: Drop ACP session/load until resume has a product shape +English | [中文](2026-06-20-drop-acp-session-load.zh.md) + Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md new file mode 100644 index 0000000000..003c66636a --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md @@ -0,0 +1,29 @@ +# RFC:移除 ACP session/load,待恢复功能具备产品形态后再引入 + +Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. + +[English](2026-06-20-drop-acp-session-load.md) | 中文 + +## 问题 + +ACP(Agent Client Protocol)当前通告 `loadSession: true` 并实现了 `session/load`:向 bridge 注入持久化能力、校验 cwd 与存储元数据的一致性、从持久化日志重建 agent、并向客户端回放先前的 transcript(文本记录)更新。这条路径有自己的竞态处理、loading-id 守卫、回放展示逻辑和测试。它还依赖规范日志保留足够的 UI 数据来重建旧的分片和工具展示。 + +持久化本身仍是基础能力,但编辑器可见的恢复功能尚未经过产品流程设计。目前没有会话选择器、没有标题/预览元数据,对加载失败或部分加载也没有清晰的用户体验。bridge 正在为一个仅由测试、文档和当前目标客户端的会话模型所使用的功能承担复杂度。 + +## 提案 + +暂时只支持新建会话。`initialize` 通告 `loadSession: false` 或省略该能力,`session/load` 不予支持。持久化仍可供 agent loop(智能体循环)和测试使用;如果其他消费方需要,恢复功能仍可作为底层工厂存在。编辑器 bridge 应在具备真正的会话选择 UX 和稳定的加载 transcript 契约后,再重新引入 `session/load`。 + +## 验收标准 + +- ACP 不再仅为 `session/load` 注入 `sessionPersistence`。 +- `initialize` 不通告加载支持。 +- `session/load` 处理器、loading-id 追踪、已加载会话的 cwd 预检以及加载回放测试全部移除。 +- 快照 fixture(测试前置数据)不再依赖加载回放的展示逻辑。 +- [ACP 文档](../../../../packages/ui/acp/README.md)仅描述新建会话的支持。 + +## 放弃了什么 + +编辑器无法通过 ACP 重新打开先前持久化的会话。这确实是一个有价值的产品功能,但当前实现超前于 UX 设计,且将 bridge 绑定在 token 级别的日志回放上。保留持久化但移除编辑器加载,将 bridge 收窄到它当前能干净呈现的工作流。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml new file mode 100644 index 0000000000..61557e984b --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-drop-acp-terminal-meta.md: 4ae3b31824ece850da0ddbc004e97b21e3cb9aad +2026-06-20-drop-acp-terminal-meta.zh.md: 62341ccae728d981400fdc22b8ec7380bbb3faf2 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md index 2f1408dc4c..4ae3b31824 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md @@ -1,5 +1,7 @@ # RFC: Drop ACP terminal `_meta` rendering +English | [中文](2026-06-20-drop-acp-terminal-meta.zh.md) + Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md new file mode 100644 index 0000000000..62341ccae7 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -0,0 +1,31 @@ +# RFC:移除 ACP 终端 `_meta` 渲染 + +Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. + +[English](2026-06-20-drop-acp-terminal-meta.md) | 中文 + +## 问题 + +ACP(Agent Client Protocol)桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 专属的终端卡片约定。已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 有意避开了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 的职责),但仍采用了参考 agent 的纯展示用 `_meta` 约定。这为 Zed 带来了更好的卡片效果,代价是桥接层状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 + +回退路径已经存在:将工具调用和完成输出渲染为普通的 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能,而非投机性的装饰。 + +## 提案 + +忽略 `clientCapabilities._meta.terminal_output`,通过普通 ACP 内容路径渲染 bash 结果。执行仍留在 agent 侧,通过 `dsh-bash` 完成;只移除与展示相关的终端元数据。如果 ACP 日后标准化了 agent 执行的终端,或产品决定 Zed 专属展示值得维护成本,终端卡片可以回归。 + +本提案比[收拢工具自有 UI 展示](2026-06-20-generic-tool-rendering.md)更窄:如果通用的 `presentCall`/`presentResult` 保留,本提案不动它们,只移除终端子形态和 `_meta` 映射。 + +## 验收标准 + +- ACP 不再读取或存储 `_meta.terminal_output` 能力状态。 +- `TerminalRendering`、终端 id、终端 cwd 解析以及 `_meta.terminal_*` update 映射从 `@deepseek-ai/dsh-acp` 中消失。 +- `ToolTerminal` 从 `@deepseek-ai/dsh-tools` 中消失,或在展示清理中因无使用而删除。 +- Bash 结果展示不再为终端 pill 解析退出状态。 +- 已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 保留在 `implemented/` 中作为已交付的历史记录,如被本提案取代则互相交叉引用。 + +## 放弃的内容 + +Zed 用户将失去专属终端卡片:没有 cwd 头部、终端展示或 exit pill。他们仍能以普通内容形式看到命令和输出。在 ACP 桥接层尚未发布、`_meta` 键仍是约定而非标准的阶段,这是合理的简化。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml new file mode 100644 index 0000000000..f0ed7ad2b5 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-drop-bash-output-spill-files.md: bbc26c645cefb1659012ca0debda78fc6850d276 +2026-06-20-drop-bash-output-spill-files.zh.md: 1439616bf55ad388e69ba693cbc7c130f4e2fc8c diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md index 0a13d90f1b..bbc26c645c 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md @@ -1,5 +1,7 @@ # RFC: Drop bash full-output spill files +English | [中文](2026-06-20-drop-bash-output-spill-files.zh.md) + Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md new file mode 100644 index 0000000000..1439616bf5 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md @@ -0,0 +1,31 @@ +# RFC:移除 bash 完整输出溢出文件 + +Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. + +[English](2026-06-20-drop-bash-output-spill-files.md) | 中文 + +## 问题 + +`dsh-bash-local` 在内存中保留有界的输出,并将大体积的 stdout/stderr 流溢出到私有临时文件。这要求维护一个私有目录、创建仅所有者可读的随机文件、处理关闭失败、按字节偏移增量读取、报告有损读取、在面向模型的文本中渲染路径,以及清理纪律。当输出被截断时,工具会告诉模型去读取一个本地溢出路径。 + +这解决了一个真实问题,但方式狭窄且有泄漏。溢出路径是一个暴露在模型输出中的进程本地文件系统产物,而非具备作用域访问、保留策略或 UI 能力的持久化 harness 产物。它还使后台任务的读取变得复杂,因为有损增量读取必须指向一到两个溢出文件。 + +## 提案 + +保留尾部截断,移除完整输出溢出文件。bash 结果包含有界的尾部内容加一个明确的截断标记;不输出路径。如果用户需要恢复完整输出,则添加一个通用的产物/blob 服务(具备显式的所有权、清理和 UI 渲染),再让 bash 将大体积输出附加到该服务。 + +本提案可以独立于[通用长时运行工具运行时](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md)落地。如果后台任务保留,`bash_output` 仍应报告输出已被丢弃,但不再公布溢出路径。 + +## 验收标准 + +- `CollectedOutput` 不再携带溢出路径。 +- `OutputCollector` 仅保留有界缓冲区,删除临时文件机制。 +- `renderResult()` 报告截断时不包含文件系统路径。 +- 测试覆盖尾部截断,不再断言完整输出文件的内容。 +- [docs/defensive-patterns.md](../../../defensive-patterns.md) 中的安全指导不再将私有溢出文件视为面向模型的接口。 + +## 放弃了什么 + +模型或用户无法再从临时文件恢复大体积命令输出中被省略的前缀。在真正的产物服务出现之前,这是可接受的。当前的溢出路径为一个生命周期和权限都未经设计的功能引入了过多的定制机制。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml new file mode 100644 index 0000000000..51d6027a24 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-drop-durable-step-boundaries.md: fba8ad4211db69d3a04d253fd9544caca0f538c6 +2026-06-20-drop-durable-step-boundaries.zh.md: 9ffa12cfba697f1e3bc9059529d0fdf97f595705 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md index 94313fd0ad..fba8ad4211 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md @@ -1,5 +1,7 @@ # RFC: Drop durable step boundary events +English | [中文](2026-06-20-drop-durable-step-boundaries.zh.md) + Status: rejected — `step/end` is the durable indication that a model step finished, and keeping the symmetric `step/start` / `step/end` pair makes crash repair, invariants, and transcript inspection clearer than inferring completion from adjacent step-scoped events. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md new file mode 100644 index 0000000000..9ffa12cfba --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -0,0 +1,32 @@ +# RFC:移除持久化的步骤边界事件 + +[English](2026-06-20-drop-durable-step-boundaries.md) | 中文 + +Status: rejected — `step/end` 是模型步骤已完成的持久化标识,保留对称的 `step/start` / `step/end` 对,在崩溃恢复、不变式检查和 transcript(文本记录)审查方面,都比从相邻的步骤级事件推断完成状态更清晰。 + +## 问题 + +会话日志存储了 `step/start` 和 `step/end` 事件,尽管每个步骤级事件本身已携带 `{ turn, step }`:assistant 分片、assistant 消息、工具调用、工具结果、token 用量和错误。`deriveMessages()` 忽略步骤边界,ACP(Agent Client Protocol)在 UI 层也忽略它们,主要消费方是不变式检查、测试、快照 golden 文件和崩溃恢复。 + +被否决的论点是:边界事件让日志更像仪式而非信息。实际上,`step/end` 是具体信息:读者无需从下一个事件推导,就能判断一次模型请求是已完成、已崩溃还是正在被修复。同样,一条孤立的 `step/start` 对于「模型请求已发起但在产出任何分片之前就失败了」的场景也有用。 + +## 提案 + +以轮次作为唯一的持久化边界。从 `SessionEventMap` 中移除 `step/start` 和 `step/end`;保留步骤级事件上用于分组的数值 `step` 字段。agent loop(智能体循环)递增步骤计数器,并以该编号记录步骤级事件,但不再追加开/关边界事件。消费方通过共享 `(turn, step)` 的连续事件推断步骤分组。 + +不变式插件应强制步骤级事件在一个已打开的轮次内具有有效的正整数步骤编号,而非要求它们被独立的边界记录包围。崩溃恢复不应合成 `step/end`;如果一个被中断的轮次被保留,恢复路径仍可关闭该轮次而无需捏造步骤边界记录。 + +## 验收标准 + +- `SessionEventMap` 不再包含 `step/start` 或 `step/end`。 +- agent loop 不再有 `closeStep()` 终结路径。 +- ACP 快照和持久化契约 fixture(测试前置数据)不再期望步骤边界行。 +- `deriveMessages()` 和回放从步骤级事件推导出相同的消息历史。 +- [事件分类体系文档](../../../architecture.md)将轮次描述为持久化边界,将步骤描述为步骤级记录上的一个字段。 +- 会话格式版本和已记录的 fixture 被刷新;按预发布格式策略,非当前版本的已存储日志被拒绝。 + +## 放弃了什么 + +日志不再将「一次模型请求已发起但进程在产出任何事件之前就终止了」记录为持久化事实,也不再有显式的「此步骤已完成」标记。在会话日志仍是持久化回放与审计表面的当下,这一损失不可接受。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml new file mode 100644 index 0000000000..aeb154fbb1 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-drop-unused-session-lineage.md: 4200532726a27e257e09f927240846f20a0b30ad +2026-06-20-drop-unused-session-lineage.zh.md: c20f964317a1924c3cbc36b7f7838f3a207a65c1 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md index fc06cc76c9..4200532726 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md @@ -1,5 +1,7 @@ # RFC: Drop unused session lineage metadata +English | [中文](2026-06-20-drop-unused-session-lineage.zh.md) + Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md new file mode 100644 index 0000000000..c20f964317 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md @@ -0,0 +1,31 @@ +# RFC:移除未使用的会话血缘元数据 + +Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. + +[English](2026-06-20-drop-unused-session-lineage.md) | 中文 + +## 问题 + +`SessionHeader.parentSession` 记录新会话从哪个会话 fork 而来。它在 `dsh-session` 中定义,被持久化后端保存,在 resume 路径中被复制,作为血缘元数据被文档记录,并有往返测试覆盖。然而仓库中没有任何已上线的 fork UI 或 subagent 流程读取它。计划中的 subagent/fork seam 仍是一个 TODO,因此该字段目前只是被存储的未来形状。 + +单文件的代价虽小,但在整个格式中分布广泛:每个后端 schema 和元数据序列化器都在保存一个尚无已完成功能读取的值。由于 header 是一份磁盘契约,即便是占位字段也会成为未来重构必须维护、迁移或有意打破的东西。 + +## 提案 + +从 `SessionHeader` 中移除 `parentSession`,直到真正的 fork/resume 功能需要血缘信息时再引入。如果存在相应 API,fork 仍然可以用先前事件来初始化新会话,但持久化的父指针应当与读取它的功能和解释它的 UX 一同引入。 + +如果血缘信息回归,届时再决定它应放在不可变 header 中、会话图索引中,还是作为一等事件。当前字段不应预先锁定那个设计。 + +## 验收标准 + +- `SessionHeader` 仅包含 version、id、createdAt 和可选的 cwd。 +- JSONL 与 SQLite 元数据 schema 不再存储 parent-session id。 +- resume 和 list API 不再往返传递 `parentSession`。 +- 文档和测试移除没有生产消费方支撑的 fork 血缘声明。 +- 会话格式版本、后端 schema 版本和录制的 fixture(测试前置数据)按需刷新;按预发布格式策略,非当前版本的已存储数据将被拒绝,不提供迁移路径。 + +## 放弃了什么 + +代码库失去一个为未来 fork/subagent UX 准备好的血缘钩子。这是有意为之。该字段在功能存在时很容易重新引入,而未发布的立场允许格式变更无需迁移。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml new file mode 100644 index 0000000000..bb1af91b9b --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-fold-session-persistence-interface.md: 695cd679c67f3e0a9f901c671c33e9507ecbe279 +2026-06-20-fold-session-persistence-interface.zh.md: 1ef93fb64cc27eeeb465136dc7dac6e751a7ccfc diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md index a59c992b0d..695cd679c6 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md @@ -1,5 +1,7 @@ # RFC: Fold the persistence interface into dsh-session +English | [中文](2026-06-20-fold-session-persistence-interface.zh.md) + Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md new file mode 100644 index 0000000000..1ef93fb64c --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -0,0 +1,31 @@ +# RFC:将持久化接口合入 dsh-session + +Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. + +[English](2026-06-20-fold-session-persistence-interface.md) | 中文 + +## 问题 + +`dsh-session-persistence` 是一个接口包(package),其核心概念已由 `dsh-session` 拥有:`SessionHeader`、`SessionEvent`、`SessionId`、`session/event` 和 `session/flush`。该包额外引入了抽象的 `SessionPersistence` 服务、共享写协调器和契约辅助工具。后端包依赖它,`agent-loop` 则需要可选地发现一个兄弟服务来实现恢复。 + +当持久化还是一个全新的可替换后端设计时,能力 seam 的拆分是合理的。但在可变摘要被移除之后,这个接口包基本上只是包装了会话日志自身的存储关注点。保持独立可能带来的仪式感多于清晰度。 + +## 提案 + +将抽象的 `SessionPersistence` 服务、协调器和持久化契约辅助工具移入 `dsh-session`。JSONL 和 SQLite 仍作为独立的后端包,注册由 session 包拥有的服务。这样既保留了后端可替换性,又删除了一个支撑包和一条跨包 seam。 + +实施 PR(Pull Request)应更新[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md) 指南,补充此例外:持久化不同于 bash 或 LLM(大语言模型),因为它的词汇和生命周期事件本身就是 session 包的核心领域。 + +## 验收标准 + +- `@deepseek-ai/dsh-session-persistence` 作为包被移除。 +- `dsh-session` 导出持久化服务类型、协调器和契约辅助工具。 +- JSONL 和 SQLite 后端包直接依赖 `dsh-session`。 +- `agent-loop` 的恢复功能使用由 session 包拥有的服务键。 +- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)、[共享持久化写协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)与[包文档](../../../../packages/session-persistence/session-persistence/README.md)说明后端实现为何仍保持独立。 + +## 放弃了什么 + +`dsh-session` 变得更重:它同时拥有内存日志和持久化接口。这就是取舍。如果第三方持久化后端已经形成公开生态,独立的接口包会是更清晰的 SDK 边界;但在预发布阶段,多出的包看起来像是在有外部消费方之前的过度抽象。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml new file mode 100644 index 0000000000..db3ac5d979 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-generic-tool-rendering.md: 07102ed3b12d7f587b1d12f0e240c1202a2e1cdd +2026-06-20-generic-tool-rendering.zh.md: 86ea52545a79ff975fc6b9c5d080e111a2750290 diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md index 6125feaa8b..07102ed3b1 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md @@ -1,5 +1,7 @@ # RFC: Collapse tool-owned UI presentation +English | [中文](2026-06-20-generic-tool-rendering.zh.md) + Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md new file mode 100644 index 0000000000..86ea52545a --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -0,0 +1,35 @@ +# RFC:收拢工具自有的 UI 展示逻辑 + +[English](2026-06-20-generic-tool-rendering.md) | 中文 + +Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. + +## 问题 + +工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身已标记出设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步堆积成一堆可选字段。ACP(Agent Client Protocol)随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从已渲染的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 + +真正的一方使用场景是 ACP 的 bash 展示。这不足以作为冻结一个跨包 UI 展示 API 的依据。 + +## 提案 + +暂时移除工具自有的 UI 展示回调。规范的工具事件已经携带工具名称、原始参数字符串、结果内容与错误状态。UI 从这些字段渲染一个通用的工具卡片。工具特有的富展示可以在至少有两个真实工具和两个真实消费方来验证词汇后,以 tagged render-intent union 的形式回归。 + +## 曾考虑的替代方案 + +一个更小的替代方案是在单个 PR(Pull Request)中将当前的可选字段包替换为一个显式 union;但如果目标是简化,更彻底的做法是删除回调、保留通用路径。 + +## 验收标准 + +- `ToolDefinition` 移除 `presentCall` 和 `presentResult`。 +- `ToolCallPresentation`、`ToolResultPresentation`、`ToolTerminal` 和 `ToolCallKind` 消失,除非一个最小的通用 UI 类型仍需要其中之一。 +- ACP 不再维护 presenter pending 状态,也不在实时流式输出/加载回放期间调用工具回调。 +- `dsh-tool-bash` 不再解析已渲染文本来恢复退出状态以生成 UI pill。 +- 快照 golden 展示通用工具卡片和文本结果。 + +## 放弃了什么 + +Bash 失去其自定义的终端风格卡片和模型撰写的描述位置。回退方案仍然合理:命令作为工具输入展示,输出作为文本展示。富展示应在产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时再行设计。 + +## 相关 + +本 RFC 是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 RFC 被接受,那个更窄的 RFC 就不再需要。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml new file mode 100644 index 0000000000..62eb41dc9a --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-retire-mid-turn-steering.md: bf78125ec175aa9152789bdccd1f8e8a16863a5b +2026-06-20-retire-mid-turn-steering.zh.md: 03af2b24916a5d1a479dc06a30d54003dfa7f156 diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md index fd1a4687b3..bf78125ec1 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md @@ -1,5 +1,7 @@ # RFC: Retire mid-turn steering +English | [中文](2026-06-20-retire-mid-turn-steering.zh.md) + Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md new file mode 100644 index 0000000000..03af2b2491 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md @@ -0,0 +1,37 @@ +# RFC:废除中途 steering(中途引导) + +Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. + +[English](2026-06-20-retire-mid-turn-steering.md) | 中文 + +## 问题 + +agent(智能体)暴露了两条用户消息路径,看起来相近但生命周期语义不同:`send()` 将一条普通用户轮次排入队列,而 `steer()` 在当前运行轮次的步骤之间注入一条消息,空闲时则回退为 `send()`。这一区别渗透到整个栈:`Agent.steer()` 是公开 API,会话日志有持久化的 `steering/message` 事件,agent 事件分类体系有 `agent/steering`,循环在排队消息 FIFO 之外还维护一个 steering FIFO,取消操作需要清空两个队列,`deriveMessages()` 必须将 steering 渲染为带标签的合成用户消息而非普通提示词。 + +continuation seam 放大了这一成本。`agent/turn-continuation` 默认为 `hadToolCalls || steeringInjected`,因此同一轮次内的 steering 消息即使模型没有请求工具调用也能强制循环再次调用模型。注释中提到了未来的 `/goal`、`/loop` 和预算守卫用途,但当前仓库没有生产级监听器;只有测试注册了该 waterfall(瀑布式事件)。另外,唯一调用 `steer()` 的生产 UI 是 stdio 演示。ACP 在轮次运行期间已经通过普通队列发送提示词。 + +## 提案 + +暂时删除中途用户 steering。`Agent.send()` 成为提交用户内容的唯一公开方式;当 agent 正在运行时,内容等待下一轮次。循环仅因工具调用而在轮次内继续,而非因为用户在某步骤运行期间输入了内容。想要中断当前轮次的调用方使用 `cancel()` 再 `send()`。 + +移除 `Agent.steer()`、steering FIFO、`steering/message`、`agent/steering`、由 steering 驱动的 continuation,以及区分排队消息与 steering 消息的取消逻辑。在同一变更中移除 `agent/turn-continuation`,除非实现 PR 发现了生产级监听器;没有 steering 之后,当前仓库不再有具体的 continuation 消费方。如果将来真正的预算或 goal 插件需要强制 continuation,应以该插件为具体消费方重新引入一个更窄的 seam。 + +## 验收标准 + +- `Agent` 暴露唯一的用户消息入口 `send()`。 +- 持久化会话事件词汇不再包含 `steering/message`。 +- `deriveMessages()` 渲染普通用户消息和上下文注入,不再有 steering 标签路径。 +- 循环只有一个排队消息 FIFO,没有同轮次用户消息 continuation 路径。 +- `agent/turn-continuation` 被移除或收窄到一个具名的生产级消费方。 +- stdio UI 和文档将运行期间的输入描述为排入下一轮次的输入。 +- 会话格式版本和录制的 fixture(测试前置数据)已刷新;按预发布格式策略拒绝非当前版本的存储日志。 + +## 放弃了什么 + +用户无法在模型处于工具步骤之间时添加同轮次 steering 内容。这种行为在理论上对「你已经在工作了,顺便也考虑一下 X」有用,但它不是 ACP 今天暴露的行为,而且它使轮次边界变得更难推理。更简单的行为是合理的:用户输入成为下一条提示词,取消仍是替换进行中工作的显式手段。 + +## 相关 + +本提案与[删除持久化步骤边界](2026-06-20-drop-durable-step-boundaries.md)天然配对,因为移除同轮次 steering 和 `agent/turn-continuation` 之后,工具调用成为一个轮次包含多个模型步骤的唯一原因。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml new file mode 100644 index 0000000000..aa8c2bbf58 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-single-session-acp-bridge.md: b7b52ca4df2d118358303745f4484e3e40a242b3 +2026-06-20-single-session-acp-bridge.zh.md: 2f00067fffcbd7a74f403c6247b290ac09a846b8 diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md index 8f1d4e4531..b7b52ca4df 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md @@ -1,5 +1,7 @@ # RFC: Return the ACP bridge to one live session per connection +English | [中文](2026-06-20-single-session-acp-bridge.zh.md) + Status: rejected — Zed is the current target ACP client and its ACP implementation is explicitly multi-session: it stores live sessions in a `HashMap<SessionId, AcpSession>`, tracks `pending_sessions`, joins concurrent loads for the same id, and tests close-during-load behavior. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md new file mode 100644 index 0000000000..2f00067fff --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md @@ -0,0 +1,31 @@ +# RFC:将 ACP 桥接恢复为每连接单会话 + +[English](2026-06-20-single-session-acp-bridge.md) | 中文 + +Status: rejected — Zed 是当前目标 ACP 客户端,其 ACP 实现明确支持多会话:它将活跃会话存储在 `HashMap<SessionId, AcpSession>` 中,跟踪 `pending_sessions`,对同一 id 的并发加载进行合并,并测试加载期间关闭的行为。 + +## 问题 + +ACP 桥接现已支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向的会话/agent 查找、逐会话的提示词状态、加载中 id、每个事件的解复用、跨会话的销毁,以及未来权限提示与后台任务的隔离问题。早先的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在跟踪未完成的权限归属部分;本 RFC 是与之竞争的简化路径。 + +产品目标已证明它需要在一个 harness 进程上承载并发的编辑器对话:Zed 的 ACP 连接拥有多个会话和加载状态。快照回放层仍然避免并发模型流,因为其回放条目是位置敏感的;这是测试 fixture 的局限,不是移除桥接多路复用的理由。 + +## 提案 + +将 ACP 的作用域收回到每连接一个活跃会话。`session/new` 或 `session/load` 创建唯一的会话记录;在现有会话被 dispose 或连接关闭之前,第二个活跃会话请求将被拒绝。如果编辑器需要多个聊天标签页,可以启动多个 agent 子进程,直到桥接具备具体的多会话 UX 和权限模型。 + +在单个 `SessionRecord | undefined` 即可满足需求的地方,移除多会话映射和解复用逻辑。桥接仍可保留使销毁行为正确的 agent/会话生命周期 seam;简化仅针对在同一传输层上多路复用多个活跃会话这一点。 + +## 验收标准 + +- ACP 每连接仅有一条活跃会话记录。 +- 当该记录存在时,`session/new` 和 `session/load` 拒绝请求。 +- 事件处理器不再跨 `Map<sessionId, record>` 解复用。 +- 多会话测试被移除,或移至继续支持多路复用的提案下。 +- 既有的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)更新为链接本 RFC,并继续作为当前方向。 + +## 放弃了什么 + +ACP 客户端无法在一个服务器进程上承载多个并发对话。这是一项有实质意义的能力削减。对于一个尚未发布的 harness 而言,更简单的模型仍然合理:一个编辑器对话对应一个 agent 进程,跨会话的权限/后台任务隔离不再是活跃的正确性负担。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml new file mode 100644 index 0000000000..32aca9d1e8 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-20-truncate-interrupted-turns.md: e17cb20f0185fe5d47d0a5ca18b7a389951fbadd +2026-06-20-truncate-interrupted-turns.zh.md: 9ded51b29323be452bd67901dea2ba7b309a9903 diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md index 1ed26c66f8..e17cb20f01 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md @@ -1,5 +1,7 @@ # RFC: Truncate interrupted final turns on load +English | [中文](2026-06-20-truncate-interrupted-turns.zh.md) + Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md new file mode 100644 index 0000000000..9ded51b293 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -0,0 +1,36 @@ +# RFC:加载时截断被中断的末尾轮次 + +Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. + +[English](2026-06-20-truncate-interrupted-turns.md) | 中文 + +## 问题 + +当前的持久化契约会保留最后一个已持久写入但从未关闭的轮次。加载时,`interruptedTurnClosers()` 扫描尾部,为未应答的工具调用合成错误的 `tool/result` 事件,在步骤未关闭时追加 `step/end`,追加 `turn/end { kind: 'interrupted' }`,并要求后端持久提交这段修复。协调器、JSONL 后端、SQLite 后端、会话事件词汇、不变式、文档和测试都对这条合成关闭路径建了模。 + +这是为了保留上一次崩溃轮次的部分工作而引入的大量机制。它还会生造从未发生过的事件。合成的工具结果有用处(它使提供方历史保持合法),但也意味着恢复后的日志中包含了没有任何工具产出过的、模型可见的文本。当前设计在尚无已发布产品、也没有真实的恢复 UX 来证明部分轮次恢复确有价值的情况下,就优化了最大化的尾部保留。 + +## 提案 + +加载时只保留到最后一个已完成的轮次。后端仍然容忍并截断撕裂的末尾记录,但如果解析出的持久前缀在一个已打开的 `turn/start` 之后结束,规范的修复方式是丢弃上一个 `turn/end` 之后的所有事件。不合成 `tool/result`,不合成 `step/end`,不追加 `turn/end { interrupted }`,也不需要 `interrupted` 轮次结束原因。 + +这使持久化的轮次边界变得简单:一个已完成的 `turn/end` 就是检查点。最后一个检查点之后的内容都是崩溃尾部。下一次提示词从最后一个已知合法的提供方 transcript 恢复,而非从部分重建的末尾轮次恢复。 + +## 验收标准 + +- `TurnEndReasonMap` 移除 `interrupted` 变体。 +- `interruptedTurnClosers()` 及其测试消失。 +- 持久化协调器的修复钩子截断后端特定的撕裂/未关闭尾部状态,不追加关闭事件。 +- [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)说明加载返回到最后一个已完成轮次,不包含部分末尾轮次。 +- 快照测试与契约测试随其所固定的行为一起更新。 +- 会话格式版本与已记录的 fixture(测试前置数据)一并刷新;按预发布格式策略,非当前版本的存储日志被拒绝,不提供迁移路径。 + +## 放弃了什么 + +一次崩溃可能丢失末尾轮次中的真实工作:上一个 `turn/end` 之后追加的助手文本、工具调用和工具输出。这是有意为之的简化。产品尚未发布,末尾轮次恢复的语义未经用户验证,而一个干净的「已完成轮次即检查点」模型在解释、测试和实现上都容易得多。未来如果需要「恢复部分崩溃工作」功能,应当设计为一个面向用户的显式恢复视图,而非静默插入规范 transcript 的合成事件。 + +## 相关 + +本 RFC 是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)和[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化步骤边界事件的大部分动机,使 [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) 的变更范围更小。 + +<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml new file mode 100644 index 0000000000..c61b774371 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-04-prune-unimplemented-subagent-vocabulary.md: 3c86f11564d85b423fe59d784c6bf69959fb3907 +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: ad8c8541ca682be6e71b6fe4ae166c3b65d14cdf diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 4c52d0b1a4..3c86f11564 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -1,5 +1,7 @@ # RFC: Prune the unimplemented subagent seam vocabulary +English | [中文](2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md) + Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md new file mode 100644 index 0000000000..ad8c8541ca --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -0,0 +1,39 @@ +# RFC:裁剪 subagent seam 中未实现的词汇 + +Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. + +[English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 + +## 问题 + +[subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:由服务在启动时检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三项启动时特性和两个可选运行时方法均无实现、无调用方: + +- **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构建 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两项;`structured` 仅由测试 mock(`packages/support/subagent-mock`)为其自身 spec 产出。服务的能力检查包含两行 assert,唯一的执行者是拒绝测试。 +- **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——连 mock 也没有;spawn spec 断言的是它们的*缺席*。 + +`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 `SchemaSpec` 类型。三个后续 subagent 工作流(per-session 快照回放、fork seed 边界、ACP 后端)都围绕这块表面落地,却没有增长出哪怕一个消费方。 + +## 提案 + +从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 和 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、mock 的 structured 分支及其 `capabilities`/`structured` 配置旋钮,以及为固定被移除表面而存在的测试(两行拒绝测试、spawn 缺席测试、mock structured spec)。从 `packages/subagent/subagent/package.json` 中删除 `dsh-tools` 的 peer/dev 依赖。更新 [subagent.md](../../../core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest,以及 `packages/subagent/subagent`、`packages/subagent/subagent-spawn`、`packages/subagent/subagent-fork` 和 `packages/support/subagent-mock` 的 README 相关行。实现 PR 按 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam RFC 的能力目录。 + +**保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是补上工具默认值,而非删除正在工作的强制逻辑。 + +审视过但有意不动的相邻表面:`SubagentService.getProvider()`/`list()` 只有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 记录了完全相同的形态曾从 bash executor 中移除后又被回退——测试 harness 对于一个在已跟踪 map 上的单行访问器而言就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) 的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent 唯一的最终消息通道);它当前未接通的桥接转发是一个待弥合的缺口或待记录的消费方,不是本 RFC 要裁剪的表面。 + +这是 [从持久化 seam 裁剪死方法](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 在 seam 词汇层面的回声:每个实现都必须为无人声明的成员——甚至更弱,因为这里连一个实现都不存在。 + +## 曾考虑的替代方案 + +### 为什么不保留? + +两类能力的设计是 seam RFC 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 RFC 作为记录仍然成立;而且 seam RFC 本身承认已交付的 `toolFilter` 形态是错的(真正的强制需要在子 agent 的上下文中设置 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此面向真实实现提供方重新添加时,将固定一份比当前推测性契约更好的契约。 + +## 验收标准 + +- 被移除的拼写仅出现在本 RFC 和修订后的 seam RFC 中;`SubagentCapabilities` 为 `{ depthLimit: boolean }`;`dsh-tools` 依赖边已消除(`hygiene` 绿)。 +- 深度强制测试不变且绿。 + +## 风险 + +subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 RFC 缩减的 seam 词汇范围内;observe-enrich RFC 记录了因缺乏消费方而删除 `agentType` 兄弟字段的判断:本 RFC 延续的正是这一判断。CC hooks 桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不触及本文移除的任何表面;observe-enrich RFC 中延期的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 RFC 模式所预期的重新添加触发点。 diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml new file mode 100644 index 0000000000..96bb401556 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-collapse-workflow-to-foreground-core.md: 78b67c10ac39fddaf4ea76ca90d5cbf760fe5866 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 567da63e86b24dbedfd6fb50da0984c9866bd9cb diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md index 7624629d41..78b67c10ac 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -1,5 +1,7 @@ # RFC: Collapse workflows to the exercised foreground core +English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) + Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md new file mode 100644 index 0000000000..567da63e86 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -0,0 +1,39 @@ +# RFC:将工作流收缩至实际使用的前台核心 + +Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. + +[English](2026-07-12-collapse-workflow-to-foreground-core.md) | 中文 + +## 问题 + +工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 仍定义了 run/phase/agent outcome 载荷,worker 仍发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 的唯一目的就是关联这些通知。 + +这套进度词汇不仅未被使用,而且在不重新设计的情况下无法服务于它唯一的具名未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具从不暴露 run id。一个全局 ACP 监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不会对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 只喂给事件,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 + +live handle 在观察者消失后仍重复事件时代的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 + +取消也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有消除任何竞态。 + +`WorkflowError.fatal` 是同类投机分支的微缩版:每个生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 + +## 提案 + +保留实际使用的核心:`agent(prompt, { schema, model })`、`parallel`、`pipeline`、`args`、并发/agent 上限、取消、有界 dispose、结构化结果、worker 隔离,以及前台工具收集。移除所有 `workflow/*` 事件及其仅服务于事件的 info/outcome 类型;移除 `phase()`、`log()`、agent 的 `label`/`phase`、phase 声明、`whenToUse` 及其 worker 消息/host 观察者;将工作流元数据收缩为工具实际使用的 name;移除仅服务于事件的 run id/meta 快照以及合成的 agent-end 账本。将 `WorkflowRun` 收缩为 `result`、`cancel()` 和 `dispose()`;工具渲染请求方持有的 name。移除 `WorkflowStartRequest.signal` 及 worker host 的 input-signal listener/disarm 状态,保留调用方从自身 abort signal 到 `run.cancel()` 的桥接。将 `WorkflowError` 变为单一的 fatal 错误类,不再有布尔模式或 `isFatalWorkflowError()` 辅助函数。 + +修订已实施的动态工作流 RFC,并更新 seam/tool/worker README、工具 schema、生成的 catalog 与包依赖图、worker type-equiv 记录、单元测试,以及工作流快照/header fixture。如果未来委托进度 UI 工作,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活此协议。 + +## 曾考虑的替代方案 + +**为未来 UI 保留预建的观测词汇。** 当前形状类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或合成的终端 end 配对。移除它意味着放弃形状兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍然缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让具名的 ACP 消费方可行。 + +## 验收标准 + +- 工作流公开 seam 仅包含有生产消费方的执行、取消、结果与 dispose 契约。 +- 不再保留任何工作流事件、phase/log 协议消息、run-id 生成器、仅服务于进度的元数据、host 配对账本或 fatal 模式分支。 +- run handle 不再有 id/meta 回显,取消在同步 `start()` 返回后只有一条持有者拥有的通道。 +- parallel/pipeline 行为、上限、取消静默、worker 隔离、结构化输出以及面向模型的工作流场景保持覆盖率。 +- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 + +## 风险 + +这是对工作流 DSL、事件分类体系、handle 与 start request 的编译可见收缩。现有提供描述性元数据的工作流调用,以及使用 `phase`、`log` 或 label 的脚本,必须相应精简;程序化调用方需自行将 abort 源桥接到返回的 handle;未来的观察者必须添加一个关联性更好的 seam。使工作流真正有用的执行语义不变。 diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml new file mode 100644 index 0000000000..1fb7c5343a --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-prune-unused-skill-registry-surface.md: 3e8c009871c3d609612b4a01edc2048ddedfad0d +2026-07-12-prune-unused-skill-registry-surface.zh.md: 1412e3cfac1adbb8b563ad50edb08c53a719feb8 diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md index cafd50e8a5..3e8c009871 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md @@ -1,5 +1,7 @@ # RFC: Prune unused skill registry surface +English | [中文](2026-07-12-prune-unused-skill-registry-surface.zh.md) + Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. ## Problem diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md new file mode 100644 index 0000000000..1412e3cfac --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md @@ -0,0 +1,29 @@ +# RFC:裁剪未使用的 skill 注册表接口 + +Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. + +[English](2026-07-12-prune-unused-skill-registry-surface.md) | 中文 + +## 问题 + +skill 服务的嵌入式运行时子系统没有任何生产调用方调用 `ctx.skills.register()`。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose 函数和测试,而这些都与每个已交付 skill 实际使用的提供方 seam 并行存在。`SkillSummary.whenToUse` 以及 candidate/definition 上的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位符。刻意开放的 `metadata` 扩展点保留不动。 + +## 提案 + +移除 `SkillService.register()`、`SkillRegistration`、运行时伪提供方及保留名称规则、运行时 revision/缓存分支,以及仅用于运行时的 source/rank 规范化逻辑。需要嵌入式 skill 的测试改为注册一个小型真实提供方。保留 `providerRevision` 作为进行中的发现纪元,但已完成的目录仅以 cwd 为键:每次提供方变更都同步清除缓存,await 之后的 revision 比较已能阻止插入陈旧结果。从 skill 契约和本地提供方副本中移除 `whenToUse`、`SkillCandidate.path` 和 `SkillDefinition.path`,同时保留提供方的 locator/root 路径;保留 `metadata`、`disableModelInvocation`、`source`、`provider`、`locator` 和 `resourceBase`,它们要么是刻意的扩展词汇,要么是生产中被消费的字段。 + +同步修订 skill 系统 RFC、README、JSDoc、目录文件和测试。agent 作用域的系统提示词段落、工具提供方和变量明确不在本提案范围内:[agent 作用域贡献者契约](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)有意允许在 `setup(agentCtx)` 期间通过 agent 拥有的上下文注册这三者,因此仓库内没有固定的作用域注册并不能证明无人消费。 + +## 曾考虑的替代方案 + +**为嵌入方保留运行时 skill 注册。** 这是已实现的 skill RFC 中一个刻意设计的同步直接定义便利接口。一个小型提供方包装层可以在 effect 拥有的生命周期下暴露相同的嵌入数据,但它必须实现异步 `list()`/`get()`、携带提供方身份、并接受提供方的重复语义。本提案选择保留一条统一的提供方路径,而非维护第二套排序、校验、缓存失效和查找路径。 + +## 验收标准 + +- skill 收集只有一条提供方驱动的路径;已完成缓存的键仅为 cwd;revision 纪元仅用于进行中的失效;保留的 skill 字段要么有生产读取方,要么有记录在案的刻意扩展契约。 +- agent 作用域的 prompt 段落、变量、工具提供方、工具守卫,以及原生模式和 Code Mode 下的结构化输出提交行为保持不变。 +- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建和 hygiene 全部通过。 + +## 风险 + +这是对预发布 skill 注册表的一次编译可见的收缩。外部编程式 `list()`/`get()` 消费方将失去 `whenToUse` 路由提示和 candidate/definition 上的 `path`;已交付的模型目录从未渲染它们,资源解析保留了显式的 `resourceBase` 加上提供方自有的不透明 locator,但这些字段在可观测性上并不等价。skill 本地的 frontmatter 解析必须继续保留并校验所支持的 metadata schema,外部提供方仍可提供嵌入式、文件系统、远程或其他 skill 来源。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index be766dbaad..97d92459f2 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -37,8 +37,154 @@ "docs/postmortem/0002-js-expression-disabled-filesystem-tools.md", "docs/postmortem/README.md", "docs/rfc/README.md", + "docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md", + "docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md", + "docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md", + "docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md", + "docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md", + "docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md", + "docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md", + "docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md", + "docs/rfc/implemented/architecture/2026-06-13-capability-seams.md", + "docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md", + "docs/rfc/implemented/architecture/2026-06-14-session-persistence.md", + "docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md", + "docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md", + "docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md", + "docs/rfc/implemented/architecture/2026-06-18-session-surface.md", + "docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md", + "docs/rfc/implemented/architecture/2026-06-20-branded-ids.md", + "docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md", + "docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md", + "docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md", + "docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md", + "docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md", + "docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md", + "docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md", + "docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md", + "docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md", + "docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md", + "docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md", + "docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md", + "docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md", + "docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md", + "docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md", + "docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", + "docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md", + "docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md", + "docs/rfc/implemented/feature/2026-06-15-code-mode.md", + "docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md", + "docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md", + "docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md", + "docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md", + "docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md", + "docs/rfc/implemented/feature/2026-06-25-ask-user-question.md", + "docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md", + "docs/rfc/implemented/feature/2026-06-30-hook-bridges.md", + "docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md", + "docs/rfc/implemented/feature/2026-06-30-interception-seams.md", + "docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md", + "docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md", + "docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md", + "docs/rfc/implemented/feature/2026-07-05-skill-system.md", + "docs/rfc/implemented/feature/2026-07-06-approval-seam.md", + "docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md", + "docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md", + "docs/rfc/implemented/feature/2026-07-07-session-prefix.md", + "docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md", + "docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md", + "docs/rfc/implemented/feature/2026-07-10-session-query-service.md", + "docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md", + "docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md", + "docs/rfc/implemented/process/2026-06-11-quality-gates.md", + "docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md", + "docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md", + "docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md", + "docs/rfc/implemented/process/2026-06-17-ts-build-config.md", + "docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md", + "docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md", + "docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md", + "docs/rfc/implemented/process/2026-06-20-rfc-classification.md", "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", + "docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md", + "docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md", + "docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md", + "docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md", + "docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md", + "docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md", + "docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md", + "docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md", + "docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md", + "docs/rfc/implemented/process/2026-07-06-node-engine-floor.md", + "docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md", + "docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md", + "docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md", + "docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md", + "docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md", + "docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md", + "docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md", + "docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md", + "docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md", + "docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md", + "docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md", + "docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md", + "docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md", + "docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md", + "docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md", + "docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md", + "docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md", + "docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md", + "docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md", + "docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md", + "docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md", + "docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md", + "docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md", + "docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md", + "docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md", + "docs/rfc/implemented/testing/2026-06-11-property-based-testing.md", + "docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md", + "docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md", + "docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md", + "docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md", + "docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md", + "docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md", + "docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md", + "docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md", + "docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md", + "docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md", + "docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md", + "docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md", + "docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md", + "docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md", + "docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md", + "docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md", + "docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md", + "docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md", + "docs/rfc/proposed/process/2026-06-11-architectural-conformance.md", + "docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md", + "docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md", + "docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md", + "docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md", + "docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md", + "docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md", + "docs/rfc/proposed/testing/2026-06-11-mutation-testing.md", + "docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md", + "docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md", + "docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md", + "docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md", + "docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md", + "docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md", + "docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md", + "docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md", + "docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md", + "docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md", + "docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md", + "docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md", + "docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md", + "docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md", + "docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md", + "docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md", "docs/testing.md", "python/README.md", "python/sdk-runtime/README.md", @@ -58,6 +204,8 @@ "docs/module-graph.md", "docs/persistence-catalog.md", "docs/rfc/INDEX.md", + "docs/rfc/implemented/AGENTS.md", + "docs/rfc/implemented/CLAUDE.md", "docs/tool-catalog.md", "docs/tool-execution-pipeline.md", "python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/" From 129549387865b8c2561fa08c2a5727bd86659ff8 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:04:38 -0700 Subject: [PATCH 004/113] docs(i18n): re-translate core batch with the prompt-v4 pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 五篇核心文档译文按 v4 基线(#348)重出:v4 模板+术语表渲染、金标 few-shot 裸文本注入、三段协议取 <final>、切换行流水线后处理;全量 机械核对(结构/切换行/术语禁项/重复括注)零异常,architecture 一处 被模型合并的列表项已按源结构拆回。 --- .worktrees/i18n-prompt-sync | 1 + docs/architecture.i18n.yaml | 2 +- docs/architecture.zh.md | 68 +++++++++++++++---------------- docs/cordis-primer.i18n.yaml | 2 +- docs/cordis-primer.zh.md | 28 ++++++------- docs/defensive-patterns.i18n.yaml | 2 +- docs/defensive-patterns.zh.md | 20 ++++----- docs/glossary.i18n.yaml | 2 +- docs/glossary.zh.md | 22 +++++----- docs/testing.i18n.yaml | 2 +- docs/testing.zh.md | 26 ++++++------ 11 files changed, 88 insertions(+), 87 deletions(-) create mode 160000 .worktrees/i18n-prompt-sync diff --git a/.worktrees/i18n-prompt-sync b/.worktrees/i18n-prompt-sync new file mode 160000 index 0000000000..7d822f3be7 --- /dev/null +++ b/.worktrees/i18n-prompt-sync @@ -0,0 +1 @@ +Subproject commit 7d822f3be7523bb5b6f3a874eed476b05ec545fd diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 9ff56c00ef..eda1339fb4 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write architecture.md: 32b9700b9aece988985ff932e597b537872f12c3 -architecture.zh.md: 1adb0111fb67c6e252153e2500732a320de44523 +architecture.zh.md: 6a4cf414da141b9d19e15d68e6b98458822b612a diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 1adb0111fb..6a4cf414da 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -2,13 +2,13 @@ [English](architecture.md) | 中文 -**DeepSeek Harness SDK** 基于 Cordis 构建 agent harness(智能体框架)。原则很简单:**一切皆插件**。内置的循环只是一个插件,不是特权内核。 +**DeepSeek Harness SDK** 基于 Cordis 构建 agent harness(智能体框架)。原则很简单:**一切皆插件**。内置的循环只是一个插件,而非特权内核。 ## 概览 -一个 harness 就是一个 [Cordis](cordis-primer.md) 上下文。各包(package)贡献服务键、类型化事件和可 dispose(资源释放)的注册:服务暴露稳定的调用(`ctx.llm`、`ctx.tools`、`ctx.sessions`),事件提供拦截与通知(`agent/request`、`tools/pre-execute`、`session/event`),注册则安装提示词段、工具、提供方、适配器或监听器。 +一个 harness 就是一个 [Cordis](cordis-primer.md) 上下文。各包(package)贡献服务键、类型化事件和可释放的注册:服务暴露稳定调用(`ctx.llm`、`ctx.tools`、`ctx.sessions`),事件提供拦截与通知(`agent/request`、`tools/pre-execute`、`session/event`),注册则安装提示词段、工具、提供方、适配器或监听器。 -`packages/core/` 组织了默认的 agent 流程;周边能力同样是一等的 Cordis 插件。 +`packages/core/` 组织了默认的 agent 流程;周围的能力同样是一等的 Cordis 插件。 ### 默认服务 @@ -27,7 +27,7 @@ |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表与流式模型调用 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台/后台命令执行 | -| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同世界进程隔离(argv 包装、逐调用策略) | +| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同世界进程隔离(argv 包装、逐次策略) | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | 模型编写的程序执行 | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | 文件系统提供方原语与策略事件 | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表与渐进式披露 | @@ -36,27 +36,27 @@ | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 命名委托提供方 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 活跃优先的逻辑语料库与精确事件读取 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 优先活跃会话的逻辑语料库与精确事件读取 | ## 事件 -事件构成服务扩展 API;详见完整的[事件目录](cordis-catalog/events.md)与[生产者/消费方映射](event-producer-consumer.md)。 +事件构成服务扩展 API;详见完整的[事件目录](cordis-catalog/events.md)与[生产方/消费方映射](event-producer-consumer.md)。 ### 事件域 -- **会话事件**是持久的、可回放的事实。轮次与步骤边界、用户输入、助手输出、工具调用、工具结果、steering(中途引导)、压缩记录以及工具拥有的持久事实追加到会话日志,并流经 `session/event`。 -- **Agent 事件**携带活跃的 `Agent` 句柄,用于状态、诊断、prompt 准入、调用配置塑形、结果校验与续行策略。 -- **能力事件**归属于拥有该动作的 seam。`tools/*`、`llm/*`、`system-prompt/*`、`fs/*` 与 `subagent/*` 让策略和适配器无需导入循环即可接入。 +- **会话事件**是持久的、可回放的事实。轮次与步骤边界、用户输入、助手输出、工具调用、工具结果、steering(中途引导)、压缩记录以及工具拥有的持久事实追加到会话日志,并通过 `session/event` 流出。 +- **Agent 事件**携带活跃的 `Agent` 句柄,用于状态、诊断、提示词准入、调用配置塑形、结果校验与续行策略。 +- **能力事件**属于拥有该动作的 seam。`tools/*`、`llm/*`、`system-prompt/*`、`fs/*` 与 `subagent/*` 让策略和适配器无需导入循环即可接入。 ### 拦截语义 -waterfall(瀑布式事件)的行为类似 around 中间件:监听器通过调用 `next()` 委托下游;不调用 `next()` 直接返回即为否决或接管。完整规则见 [Cordis waterfall 语义](cordis-primer.md#cordis-waterfall-semantics)。 +waterfall(瀑布式事件)的行为类似环绕中间件:监听器通过调用 `next()` 委托下游;不调用 `next()` 直接返回则表示否决或接管。完整规则见 [Cordis waterfall 语义](cordis-primer.md#cordis-waterfall-semantics)。 ## 默认循环生命周期 -内置循环消耗工作队列、组装请求、流式接收模型回答、执行工具、应用续行策略并持久化检查点。每一个暂停点都是一个服务调用或事件,可供插件介入。 +内置循环排空工作队列、组装请求、流式接收模型回答、执行工具、应用续行策略并持久化状态检查点。每个暂停点都是一个对插件可用的服务调用或事件。 -**会话**是一个 agent 的仅追加事件日志。**轮次(turn)**消耗一批排队消息,运行到模型不再请求工具且没有插件要求续行为止。**步骤(step)**是一次模型请求加上该响应引发的工具执行。下面的流程中([时序图伴侣文档](agent-lifecycle.md)),带引号的名称是持久化的会话事件,事件名称是扩展点。 +**会话**是一个 agent 的仅追加事件日志。**轮次**排空一批排队消息,运行直到模型不再请求工具且没有插件请求续行。**步骤**是一次模型请求加上该响应引发的工具执行。下文流程([时序伴随文档](agent-lifecycle.md))中,带引号的名称是持久会话事件,事件名称是扩展点。 ### 轮次流程 @@ -96,76 +96,76 @@ forever: checkpoint persistence and notify idle/running status ``` -循环每步骤渲染一次 prompt 组装。插件贡献有序段、工具 schema 与 `{{name}}` 变量;未知或无值的引用会使轮次失败,而非带着空洞发送。`dsh-system-prompt` 拥有 harness 身份与默认部署人格;agent 作用域的人格可以遮蔽默认值。循环提供 `model` 和 `cwd`。见 [prompt 所有权 RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 +循环每个步骤渲染一次提示词组装。插件贡献有序段、工具 schema 和 `{{name}}` 变量;未知或无值的引用会使轮次失败,而非带着空洞发送。`dsh-system-prompt` 拥有 harness 身份与默认部署人设;agent 作用域的人设可以遮蔽默认值。循环提供 `model` 和 `cwd`。见[提示词归属 RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 -Post-tool 上下文在所有工具结果之后落入,以保持 tool-call/result 的邻接稳定。steering 在步骤之间排空;轮次结束后的普通剩余 steering 作为输入重新入队。终止性的 `agent/turn-stop` 是显式例外:它在普通续行与 steering 折叠之后运行,然后在轮次关闭和刷新期间保持权威,因此这些后续监听器产生的 steering 被丢弃而非成为新的步骤或轮次;普通排队的 prompt 则被保留。 +工具后上下文在所有工具结果之后追加,以保持工具调用/结果的邻接稳定。Steering 在步骤之间排空;轮次结束后的普通剩余 steering 作为输入重新入队。终止性的 `agent/turn-stop` 是显式例外:它在普通续行与 steering 折叠之后运行,然后在轮次关闭和刷新期间保持权威,使后续监听器产生的 steering 被丢弃而非变成另一个步骤或轮次;普通排队的提示词则被保留。 ### 失败边界 -轮次是容错边界。抛出异常的监听器、适配器错误结束或失败的步骤会以错误原因结束当前轮次,并通过 `agent/error` 报告实时诊断;它不会杀死驱动循环。`cancel()` 清除排队与 steering 工作,在可能时中止活跃的模型/工具边界,并记录相应的轮次结束。dispose 停止循环、等待静默、注销 agent,并让服务 disposer 排空。 +轮次是容错边界。抛出异常的监听器、适配器错误结束、或失败的步骤会以错误原因结束当前轮次,并通过 `agent/error` 报告实时诊断;它不会终止驱动循环。`cancel()` 清除排队和 steering 工作,在可能时中止活跃的模型/工具边界,并记录相应的轮次结束。dispose(资源释放)停止循环、等待静默、注销 agent,并让服务的 disposer 排空。 -每个会话事件都被轮次包围。重新加载崩溃的会话时,系统保留中断的尾部并以合成的 `interrupted` 轮次结束关闭它。持久化轮次已关闭后的失败仅通过 `agent/error` 报告,因为已没有安全的轮次内位置。轮次以一个 `TurnEndReason` 结束(`completed`、`aborted`、`error`、`disposed`、`max-tokens`、`rejected` 或 `interrupted`);各变体的语义见 [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 +每个会话事件都被轮次包围。重新加载崩溃的会话时,中断的尾部被保留,并以合成的 `interrupted` 轮次结束关闭。持久轮次已关闭之后发生的失败仅通过 `agent/error` 报告,因为已没有安全的轮次内位置。轮次以一个 `TurnEndReason` 结束(`completed`、`aborted`、`error`、`disposed`、`max-tokens`、`rejected` 或 `interrupted`);各变体的语义见 [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 拥有活跃 agent 并返回 `AgentHandle { agent, dispose() }`。`Agent` 是其他插件驱动的 API:`send()` 入队工作,`steer()` 注入轮次中内容,`inject()` 追加上下文并在空闲时开启一次性注入轮次,`cancel()` 是公开的停止原语,`whenIdle()` 观察静默状态。调用方 fiber 与具体工厂提供方在结构上共同拥有编程式生命周期;消费方句柄是唯一的非结构性拆卸能力,且每个所有者到达同一个被 await 的 disposer。 +`ctx.agents` 拥有活跃 agent 并返回 `AgentHandle { agent, dispose() }`。`Agent` 是其他插件驱动的 API:`send()` 入队工作,`steer()` 注入轮次中内容,`inject()` 追加上下文并在空闲时开启一次性注入轮次,`cancel()` 是公开的停止原语,`whenIdle()` 观察静默状态。调用方 fiber 与具体工厂提供方在结构上共同拥有编程式生命周期;消费方句柄是唯一的非结构性拆除能力,每个所有者到达同一个被等待的 disposer。 ### Agent 作用域 -每个活跃 agent 拥有一个作用域化的 `agent.ctx`。其注册遮蔽同名全局注册,只接收该 agent 的派发,并随 agent 一起解除。`CreateAgentOptions.setup(agentCtx)` 在发布前组合作用域。[语义门禁 RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) 定义了类型化解析器,从合并的 `Events` 签名与 `scopeTarget` 派生载体检查,消除了手写事件表。见 [agent 作用域 RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md);subagent 组合控制另行记录于[此](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 +每个活跃 agent 拥有一个作用域化的 `agent.ctx`。其注册遮蔽同名全局注册,只接收该 agent 的分发,并随 agent 一起卸载。`CreateAgentOptions.setup(agentCtx)` 在发布前组合作用域。[语义门禁 RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) 定义了类型化解析器,从合并的 `Events` 签名与 `scopeTarget` 推导载体检查,消除了手写事件表。见 [agent 作用域 RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md);subagent 组合控制另行[文档化](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 ## 状态 ### 会话日志 -会话日志是真源。`deriveMessages()` 将会话事件投影为发送给模型的 `Message[]`;原始 `assistant/chunk` 事件留在日志中用于回放和 UI 保真。回放、fork、恢复、transcript(文本记录)渲染、遥测和持久化都从同一事件流派生。 +会话日志是真源。`deriveMessages()` 将会话事件投影为发送给模型的 `Message[]`;原始 `assistant/chunk` 事件保留在日志中,用于回放和 UI 保真。回放、fork、恢复、transcript(文本记录)渲染、遥测与持久化都从同一事件流派生。 -**模型可见 ⟺ 已记录**:日志能重建每次请求——`step/start` 处的消息前置 header 的会话前缀,header 通过折叠 `request/header` 得出——开发不变式对此做断言([可重建性 RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:日志重建每个请求(`step/start` 处的消息以 header 的 session prefix 为前缀,header 通过折叠 `request/header` 得出),开发不变式对此进行断言([可重建性 RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md))。 持久性是插件关注点。持久化后端缓冲同步的 `session/event` 通知,循环在轮次结束检查点完成后才继续。`SessionPersistence` seam 直接存储 `SessionEvent`,元数据在 `SessionHeader` 中;JSONL 与 SQLite 共享同一套契约测试。 ### 模型内容 -消息是类型化内容块(`text`、`reasoning`、`tool-call`、`tool-result`)的数组。联合类型派生自可合并扩展的 `ContentBlockMap`;同一模式也用于 `MessageSource`、`FinishReason`、`TurnTrigger` 与 `TurnEndReason`。新的块类型需要跨适配器、UI 桥接、压缩计价与持久化协调,因此块类型仍是仓库级契约。 +消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`)。联合类型派生自可合并扩展的 `ContentBlockMap`;同一模式也用于 `MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason`。新的块类型需要跨适配器、UI 桥接、压缩计价和持久化协调,因此块类型仍是仓库级契约。 -流式输出是原始分片协议(从 `block-start` 到 `finish`),`BlockAssembler` 是共享的 chunk 到 block 组装器。循环在组装分片以供派发的同时记录原始 chunk。`LlmAdapter` 是提供方 seam:继承它、实现 `stream()`、用 `ctx.llm.registerAdapter(models, adapter)` 注册。StreamChunk 约定见 [llm-streaming.md](core-data-structures/llm-streaming.md)。 +流式输出是原始分片协议(从 `block-start` 到 `finish`),`BlockAssembler` 是共享的分片到块组装器。循环在组装分片以供分发的同时记录原始分片。`LlmAdapter` 是提供方 seam:继承、实现 `stream()`,然后通过 `ctx.llm.registerAdapter(models, adapter)` 注册。StreamChunk 约定见 [llm-streaming.md](core-data-structures/llm-streaming.md)。 ## 扩展与组合 ### 能力模式 -一个可替换的能力通常拆分为**接口 / 实现 / 消费方**:接口拥有其 `ctx` 键与事件,实现注册后端,消费方通过工具或 prompt 暴露模型行为。Bash 是参考实现;[能力图](capability-seams.md)展示了每个族。 +一个可替换的能力通常拆分为**接口/实现/消费方**:接口拥有其 `ctx` 键和事件,实现注册后端,消费方通过工具或提示词暴露模型行为。Bash 是参考实现;[能力图](capability-seams.md)展示了每个族。 -部分 seam 有意偏离模板。LLM(大语言模型)将接口与消费方词汇放在一起,因为适配器就是实现。文件系统在提供方原语周围添加策略门禁。Web 是一个服务加搜索/抓取两个提供方注册表,因此提供方替换不会重命名模型工具。skill 与 subagent 使用命名提供方注册表;本地 skill 扫描项目/用户根目录,其他提供方可以在不改动注册表/工具的情况下添加嵌入式或远程目录。subagent 可以全新 spawn、从父级已完成轮次的前缀 fork,或使用 ACP 子进程([subagent.md](core-data-structures/subagent.md))。 +部分 seam 有意偏离模板。LLM 将接口与消费方词汇放在一起,因为适配器就是实现。文件系统在提供方原语周围增加了策略门。Web 是一个服务加搜索/抓取两个提供方注册表,因此替换提供方不会重命名模型工具。Skills 和 subagents 使用命名提供方注册表;本地 skills 扫描项目/用户根目录,其他提供方可以添加嵌入式或远程目录而无需修改注册表/工具。Subagents 可以全新 spawn、从父级已完成轮次的前缀 fork,或使用 ACP 子进程([subagent.md](core-data-structures/subagent.md))。 ### Bundle 与应用 -`dsh-agent-spine-demo` 是默认的组合 bundle:一个插件加载共享主干([README](../packages/examples/agent-spine-demo/README.md))。应用包将其与前端入口和启动 `bin` 组合:`dsh-stdio-demo` 用于终端 REPL,`dsh-acp-demo` 用于基于 JSON-RPC stdio 的 ACP(无 stdout logger)([ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 则启动外部 `cordis.yml`;Python SDK 在未设置显式配置通道时注入包默认值,并通过行分隔的 stdio JSON-RPC 驱动 `dsh-jsonrpc`([Python SDK](../python/README.md))。一个部署就是一片薄薄的 `cordis.yml` 叶子:可替换的后端、一个应用入口和可选的产品工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[关系图索引](graph-atlas.md))。 +`dsh-agent-spine-demo` 是默认的组合 bundle:一个插件加载共享主干([README](../packages/examples/agent-spine-demo/README.md))。应用包在其上组合前端入口和启动 `bin`:`dsh-stdio-demo` 用于终端 REPL,`dsh-acp-demo` 用于通过 JSON-RPC stdio 提供 ACP 且不带 stdout 日志([ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 则启动外部 `cordis.yml`;Python SDK 仅在未设置显式配置通道时注入包默认值,并通过行分隔的 stdio JSON-RPC 驱动 `dsh-jsonrpc`([Python SDK](../python/README.md))。一个部署就是一片薄薄的 `cordis.yml` 叶子:可替换的后端、一个应用入口,加上可选的产品工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[关系图索引](graph-atlas.md))。 ### 新行为的归属 -新行为应接入已记录的扩展点;修改内置循环需要同步更新本映射。 +新行为应接入已文档化的扩展点;修改内置循环需要同步更新此映射表。 | 目标 | 机制 | |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | -| 添加面向模型的能力 | 在 `ctx.tools` 上注册工具;schema 流入 prompt 组装 | +| 添加面向模型的能力 | 在 `ctx.tools` 上注册工具;schema 流入提示词组装 | | 添加命令执行 | 实现并注册 `ctx.bash` 后端 | | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方或监听 `fs/*` 策略事件 | | 隔离 spawn 的进程 | 一个 `ctx.sandbox` 后端;消费方在 spawn 前包装 argv | -| 拦截 prompt、请求、工具使用或续行 | 监听相关的 `agent/*` 或 `tools/*` waterfall;使用串行 `agent/turn-stop` 实现单调终止 | +| 拦截提示词、请求、工具使用或续行 | 监听相关 `agent/*` 或 `tools/*` waterfall;使用串行 `agent/turn-stop` 实现单调终止停止 | | 添加历史之外的会话稳定请求前缀 | 在 `agent/session-prefix` 上组合,每个循环实例一次;记录在请求 header 上 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | -| 添加持久化会话状态 | 添加 `SessionEventMap` 成员并从日志渲染/回放 | -| fork 活跃会话 | 使用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| 将工具、prompt 段或监听器限定到单个 agent | 通过该 agent 的 `agent.ctx` 注册(见 Agent 作用域) | +| 添加持久会话状态 | 添加 `SessionEventMap` 成员并从日志渲染/回放 | +| Fork 活跃会话 | 使用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| 将工具、提示词段或监听器限定到单个 agent | 通过该 agent 的 `agent.ctx` 注册(见 Agent 作用域) | -[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)提供插件骨架与功能到 seam 的映射;分步指南覆盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)与[vendor 包](cookbook/adding-a-vendored-package.md)。 +[扩展实操手册](cookbook/extension-cookbook.md)提供插件骨架和功能到 seam 的映射;分步指南覆盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)与 [vendor 包](cookbook/adding-a-vendored-package.md)。 ## 快速参考 - 领域术语见[术语表](glossary.md) - 类型定义见 [core-data-structures/](core-data-structures/core.md) -- 精确的事件与服务签名见[事件目录](cordis-catalog/events.md) -- [服务目录](cordis-catalog/services.md) +- 精确的事件与服务签名见[事件](cordis-catalog/events.md) +- 与[服务](cordis-catalog/services.md)目录 - 包契约见[包映射](../packages/README.md) - [RFC](rfc/README.md) diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 405a377ed1..5345efe3f0 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write cordis-primer.md: 39d3d97b9ac43fec50cb0c832af449fc8bc6232f -cordis-primer.zh.md: 4915f665cae51b44f89190d43d147e5cda0df146 +cordis-primer.zh.md: f941c90489e1153910ed809d2cef30ece8539f8f diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index 4915f665ca..f941c90489 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -2,19 +2,19 @@ [English](cordis-primer.md) | 中文 -Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。本入门文档讲解 harness 插件作者在阅读生成的[事件](cordis-catalog/events.md)与[服务](cordis-catalog/services.md)目录之前需要了解的 Cordis 核心概念。vendor 源码与同步流程见 [vendor/README.md](../vendor/README.md)。 +Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。本文介绍 harness 插件作者在阅读生成的[事件](cordis-catalog/events.md)与[服务](cordis-catalog/services.md)目录之前需要了解的 Cordis 核心概念。vendor 源码与同步流程见 [vendor/README.md](../vendor/README.md)。 -## Cordis 五大理念 +## 五个核心概念 -- **插件是实现了 Service 的对象。** 它可以是一个带有可选 `inject` 和 `apply(ctx)` 字段的函数,也可以是一个 `Service` 子类,其生命周期由 Cordis 挂载到当前上下文中。 -- **上下文是服务的注册表。** 一个服务在上下文中声明一个稳定的 `ctx.<key>`(如 `ctx.tools`、`ctx.llm`、`ctx.sessions`);其他插件通过 key 查找服务,而非导入具体实现。 -- **通过 `inject` 声明服务依赖。** 插件声明所需的服务后,会等待这些服务就绪;加载顺序通过服务依赖表达,而非手动编排启动序列。 -- **类型化事件用于通信。** 服务通过 TypeScript 声明合并定义事件名,然后以 `emit`、`waterfall`(瀑布式事件)、`parallel` 或 `serial` 方式分发,分别对应监听者观察、包装、并行扇出或按序执行。 -- **注册是可逆的副作用。** 提示词片段、工具 schema、适配器、提供方和监听器通过 `ctx.effect()` 或 `ctx.on()` 安装,因此重载和拆卸能可预测地回退它们。 +- **插件是实现 Service 的对象。** 它可以是一个带有可选 `inject` 和 `apply(ctx)` 字段的函数,也可以是一个 `Service` 子类,其生命周期由 Cordis 挂载到当前上下文中。 +- **上下文是服务的容器。** 一个服务占据一个稳定的 `ctx.<key>`(如 `ctx.tools`、`ctx.llm`、`ctx.sessions`);其他插件通过 key 查找服务,而非导入具体实现。 +- **通过 `inject` 声明服务依赖。** 插件声明所需的服务后,会等待这些服务就绪才启动;加载顺序通过服务依赖表达,而非手动编排启动序列。 +- **类型化事件用于通信。** 服务通过 TypeScript 声明合并注册事件名,然后以 `emit`、`waterfall`(瀑布式事件)、`parallel` 或 `serial` 方式分发,分别对应监听者观察、包装、并行扇出或按序执行。 +- **注册是可逆的副作用。** 提示词片段、工具 schema、适配器、提供方和监听器通过 `ctx.effect()` 或 `ctx.on()` 安装,reload 和 teardown 时可预期地回卷。 ## 分发模式 -每个事件具有以下分发模式之一,且只能通过对应的方法分发。 +每个事件具有以下分发模式之一,且只能通过对应方法分发。 | 模式 | 是否 await? | 分发顺序 | 是否有返回值? | |---|---|---|---| @@ -23,22 +23,22 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 | `parallel` | 是 | 所有监听器并行观察事件 | 否 | | `serial` | 是 | 监听器按注册顺序观察 | 是 | -分发模式是事件公开契约的一部分。新的 harness 事件通过 `@mode` 标签记录它,以便生成的目录能将声明与分发站点进行交叉校验。 +分发模式是事件公开契约的一部分。新的 harness 事件通过 `@mode` 标签记录模式,以便生成的目录可以将声明与分发调用点做交叉校验。 ## Cordis Waterfall 语义 `ctx.waterfall` 是环绕中间件。监听器接收 `(...args, next)`。调用 `next()` 将可能经过包装的结果委托给下一个服务;不调用 `next()` 直接返回则短路。值通过 `next()` 的返回值向下传播。 -协作式监听器通常修改一个共享的请求或决策对象,然后委托。监听器也可以选择完全替换结果,下游监听器只会看到替换后的结果。仅当监听器必须在普通注册之前运行时才使用 `prepend: true`。 +协作式监听器通常修改一个共享的请求或决策对象,然后委托。监听器也可以选择完全替换结果,下游监听器将只看到替换后的结果。仅当监听器必须在普通注册之前运行时才使用 `prepend: true`。 -对于单决策事件,短路是设计意图。策略监听器在拥有决策权时可以不调用 `next()` 直接返回,而仅做标注或观察的监听器必须委托。 +对于单决策事件,短路是设计意图。策略监听器在拥有决策权时可以不调用 `next()` 直接返回,而仅做标注或观察的监听器则必须委托。 ## Loader 配置 -`@cordisjs/plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 进行插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个真值对象,总是会禁用该条目。当需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖。 +`@cordisjs/plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 ## 实践规则 -将行为封装到插件中:工具流水线事件属于 `ctx.tools`,模型流式输出属于 `ctx.llm`,实时 agent 协调属于 `ctx.agents`。拦截和策略优先使用事件;直接能力调用优先使用服务方法。 +将行为封装为插件:工具流水线事件属于 `ctx.tools`,模型流式输出属于 `ctx.llm`,实时 agent(智能体)协调属于 `ctx.agents`。拦截和策略优先使用事件;直接能力调用优先使用服务方法。 -每个注册都应有对应的 dispose(资源释放)器:要么从 `ctx.effect()` 返回一个,要么使用 Cordis 提供的辅助函数自动处理。如果拆卸顺序有要求,请将相关工作放在同一个 effect 中,以确保 dispose 按预期顺序回退。 +每个注册都应有对应的 disposer(dispose(资源释放)函数):要么从 `ctx.effect()` 返回一个,要么使用 Cordis 提供的辅助方法自动处理。如果 teardown 顺序有要求,请将相关工作放在同一个 effect 中,以确保资源释放按预期顺序回卷。 diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 03765e44ae..96bb5de13f 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write defensive-patterns.md: fda0be0d2d3b7fa099162123b3d219673eebd07d -defensive-patterns.zh.md: 60d7389db50c30e2b85fd88b320f04b43e22d84d +defensive-patterns.zh.md: f6e4712a4a239c954193f63f32285037eb6d4f0e diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index 60d7389db5..f6e4712a4a 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -2,28 +2,28 @@ [English](defensive-patterns.md) | 中文 -来之不易的缺陷类别规则:以下每条模式都是本项目中实际发布或险些发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前,请先阅读本文。测试层面的对应规则(真实入口路径、world 验证、资源归属)见 [testing.md](testing.md)。 +来之不易的缺陷类别规则:下面每条模式都是本项目实际发布或差点发布的一类缺陷,以防止其复发的规则形式陈述。在编写生命周期、并发、子进程或清理代码之前请先阅读本文。测试层面的对应规则(真实入口路径、world 验证、资源归属)见 [testing.md](testing.md)。 ## 正交结果独立上报 -一个结果可以同时具有多重性质:进程可能既超时又以 exit 0 退出,因为它捕获了信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应独立暴露;永远不要把一个 flag 的上报嵌套在另一个 flag 的分支内,否则调用方会把一次被截断的运行误读为正常成功。 +一个结果可以同时具有多重性质:进程可能既超时又以 exit 0 退出,因为它捕获了信号。每个独立事实(`timedOut`、`signal`、`exitCode`)都应独立暴露;切勿将某个 flag 的上报嵌套在另一个 flag 的分支内,否则调用方会把一次被截断的运行误读为正常成功。 -## 在接口两侧都遵守跨 seam 契约 +## 跨 seam 契约两侧都要遵守 -当接口文档记录了两种有效的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须两种都处理,而不只是第一个实现碰巧使用的那种。基于库的适配器在流中途无法抛出异常,只能依赖带内路径;如果 agent loop 只捕获 throw,就会把提供方的 401 变成一个正常完成的轮次。请在类型定义处记录契约;通过真实消费方测试每个分支。 +当一个接口文档记录了两种合法的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须同时处理两种路径,而不是只处理第一个实现恰好使用的那种。依赖库的适配器可能无法在流中途抛出异常,只能走带内路径;如果 agent loop(智能体循环)只捕获抛出的异常,就会把提供方的 401 错误变成一个正常完成的轮次。请在类型定义处记录契约;通过真实消费方测试每个分支。 ## 异步状态不是同步状态 -`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞态;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。永远不要基于一个你刚刚请求的状态来控制流程——应当基于实际触发的事件/promise 来驱动生命周期(`agent/status`、`task.done`),并观察状态转换(先看到 `running` 再看到 `idle`),而不是假设你发出的动作与轮次 1:1 对应(循环会批量处理排队的消息)。这条守则是双向的:如果等待的转换永远不会发生(EOF 且没有提交过工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 +`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而非计数你假定与轮次一一对应的操作(循环会批量处理排队消息)。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 -## dispose 必须达到静止,而非仅仅请求停止 +## Dispose 必须达到静止,而不仅仅是请求停止 -一个只发出 kill/abort 就返回的清理逻辑会留下孤儿进程。请让清理逻辑异步化并 await 子进程退出(kill → await `done`),并在 kill 之前关闭监听器/通知注册表,使迟到的完成事件保持静默。测试应证明 dispose 确实等到了进程退出(`await fiber.dispose()` 之后 pid 已不存在),而非仅仅证明进程最终会死。 +一个清理流程如果发出 kill/abort 后就返回、而不等待工作实际停止,就会留下孤儿进程。请让清理逻辑异步化并 await 子进程退出(kill → await `done`),并在 kill 之前关闭监听器/通知注册表,使迟到的完成事件保持静默。测试应证明 dispose 确实等待了(`await fiber.dispose()` 之后 pid 已不存在),而不仅仅是进程最终会死。 ## 在边界处包容回调异常 -用户提供的监听器抛出异常时,不得导致它所在的 promise 被 reject,也不得饿死排在它之后的监听器。请在分发循环中用 try/catch 包裹并记录日志;一个有问题的订阅者永远不能破坏核心生命周期。 +用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。 -## 永远不要把环境变量或可预测路径暴露给不可信输出 +## 绝不将环境变量或可预测路径暴露给不可信输出 -spawn 的命令应获得一个经过清洗的 env(移除 `*KEY*`/`*SECRET*`/`*TOKEN*`),确保 harness 凭证不会泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可打开模式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞态和信息泄露。 +spawn 的命令应获得一份经过清洗的 env(去除 `*KEY*`/`*SECRET*`/`*TOKEN*`),使 harness 凭证无法泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可访问打开方式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞争和信息泄露。 diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index a0765ef752..fe099eb156 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write glossary.md: 23eebc5793e8232482a796f5bde1e794f0556208 -glossary.zh.md: 7163015eeed71c96743b9cae491db206585a70b2 +glossary.zh.md: a233f926ba3af0a4e90de90bf21d68a2244c3ea6 diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index 7163015eee..a233f926ba 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -2,18 +2,18 @@ [English](glossary.md) | 中文 -DeepSeek Harness SDK 的领域词汇对每个概念使用唯一的规范术语。各术语通过标准 Markdown 锚点互相链接;实现细节留在各 package README 和 RFC 中。 +DeepSeek Harness SDK 的领域词汇对每个概念使用唯一的规范术语。各术语通过标准 Markdown 锚点互相链接;实现细节留在各 package README 与 RFC 中。 FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. -## agent 作用域 +## agent-scope -- **scope(作用域)**:按 agent(智能体)注册的单位。一项贡献(工具、prompt 段落、变量、限制、监听器)要么是*全局*的(对所有 agent 可见),要么是*有作用域*的(归属于恰好一个 [scope key](#scope-key))。只有两层,扁平结构:有作用域的注册不会向下继承给 subagent;子树行为通过[血统](#lineage)数据表达,从不通过作用域结构。 -- **scope key(作用域键)**:作用域的不透明标识,按对象同一性比较。harness 约定:一个活跃的 agent 就是其自身作用域的 key。<a id="scope-key"></a> -- **agent context(`agent.ctx`)**:agent 的有作用域上下文;通过它进行的注册既是作用域可见的,也是作用域生命周期的(一个事实同时驱动两者),其上的监听器参与该 agent 的作用域过滤分发。注册表主体事件可以在其自身的事件契约下有意保持不过滤。 -- **scope carrier(作用域载体)**:作用域过滤分发所携带的 `thisArg`(由 `scopeTarget` 构建);其过滤器放行无标签监听器加上主体自身的监听器。*无主体*的载体(没有 key)只放行无标签监听器。 -- **scoped dispatch(作用域分发)**:规则是:关于某个 agent 活动的事件以该 agent 的载体进行分发。关于注册表本身的事件(如「一个工具被添加」)属于*注册表主体*事件,保持不过滤。 -- **shadowing(遮蔽)**:最具体者胜出的名称解析:一个有作用域的工具/段落/变量仅在该作用域内替代其同名的全局副本。这是按 agent 定制人设和按 agent 定制工具变体的机制。 -- **restriction / scope-local registration(限制 / 作用域局部注册)**:限制(`tools.restrict`)为单个作用域过滤全局工具面(按交集组合);作用域局部注册在过滤之后合并。被过滤掉的全局工具既不出现在 prompt 中,也拒绝执行,与不存在的工具无法区分。 -- **setup window(设置窗口)**:创建者组装 agent 有作用域世界的创建时隙(`CreateAgentOptions.setup`):在作用域和 agent 对象已存在、但 agent 或会话尚未发布、`agent/session-start` 尚未触发、首次 prompt 尚未组装之前。设置窗口只做注册,从不驱动 agent。 -- **lineage(血统)**:以数据形式携带的父子关系(`parentSession`、`subagentDepth`);从不影响可见性。<a id="lineage"></a> +- **scope**:按 agent(智能体)划分的注册单位。一项贡献(工具、提示词片段、变量、限制、监听器)要么是*全局的*(对所有 agent 可见),要么是*有范围的*(归属于恰好一个 [scope key](#scope-key))。只有两层,扁平结构:有范围的注册不会向下继承给 subagent;子树行为通过 [lineage](#lineage) 数据表达,从不通过 scope 结构。 +- **scope key**:scope 的不透明标识,按对象同一性比较。harness 约定:一个活跃的 agent 就是其自身 scope 的 key。<a id="scope-key"></a> +- **agent 上下文(`agent.ctx`)**:agent 的有范围上下文;通过它进行的注册既是 scope 可见的,也是 scope 生命周期的(同一事实决定两者),其上的监听器参与该 agent 的 scope 过滤分发。注册表主体事件可以在各自的事件契约下保持故意不过滤。 +- **scope carrier**:scope 过滤分发所携带的 `thisArg`(由 `scopeTarget` 构建);其过滤器放行无标签监听器加上主体自身的监听器。*无主体*的 carrier(没有 key)只放行无标签监听器。 +- **scoped dispatch**:规则是:关于某个 agent 活动的事件以该 agent 的 carrier 进行分发。关于注册表本身的事件(如「一个工具被添加了」)属于*注册表主体*事件,保持不过滤。 +- **shadowing**:最具体者胜出的名称解析:一个有范围的工具/片段/变量仅在该 scope 内替换同名的全局对应项。这是按 agent 定制 persona 和按 agent 定制工具变体的机制。 +- **restriction / scope-local 注册**:restriction(`tools.restrict`)为单个 scope 过滤全局工具表面(多个 restriction 取交集组合);scope-local 注册在过滤之后合并。被过滤掉的全局工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分。 +- **setup window**:创建者组装 agent 有范围世界的创建时隙(`CreateAgentOptions.setup`):在 scope 和 agent 对象已存在、但 agent 或会话尚未发布、`agent/session-start` 尚未触发、首次提示词尚未组装之前。setup 只做注册,从不驱动 agent。 +- **lineage**:以数据形式携带的父子关系事实(`parentSession`、`subagentDepth`);从不影响可见性。<a id="lineage"></a> diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 3adcf28723..e91d6712ea 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write testing.md: ddb9da0b38e5dc9cc75ede81ec157c4744fd11c2 -testing.zh.md: 6d21a37b175c8052fbc34db0fc5a9b522c27f7ec +testing.zh.md: 8a37a9eaffbf19b205e3b98e7609464133060cf2 diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 6d21a37b17..8a37a9eaff 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -2,34 +2,34 @@ [English](testing.md) | 中文 -本文说明本仓库如何逐层测试,以及保持绿色测试套件有意义的规则。命令见根目录 [AGENTS.md](../AGENTS.md);关联 RFC 承载设计动机。 +本文说明本仓库的分层测试方式,以及保持绿色测试套件有意义的规则。命令见根目录 [AGENTS.md](../AGENTS.md);关联的 RFC 承载设计动机。 ## 层级 -- **单元测试**(`pnpm run test`):vitest 运行 `packages|examples/*/tests/**/*.spec.ts`,与被测代码同目录。每个注册表都有一个 HMR(热模块替换)安全测试(dispose 贡献该注册的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态与永久契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 -- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,绝非充分条件:它证明代码行被执行过,不证明功能按交付预期工作。 -- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试,对接真实提供方 API——DeepSeek 模型加各提供方独立冒烟测试(各自依赖自己的密钥:`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等);每个套件在缺少对应密钥时自动跳过,确保无密钥 CI 保持绿色([真实 API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照测试**(`pnpm run test:snapshot`):启动真实示例子进程,无密钥回放录制的会话,将归一化后的 stdout 与重新持久化的日志同已提交的 golden 文件做 diff([快照 RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md))。当模型 transcript(文本记录)需要变更时使用 `pnpm run test:snapshot:record`;当已提交的 transcript 仍是正确的 mock LLM(大语言模型)输入、只需无密钥重写回放 golden 时使用 `pnpm run test:snapshot:refresh`。请评审 golden diff。系统提示词/工具 schema 内容由一个场景(`text-turn`)固定,其余 fixture(测试前置数据)中以 token 化形式引用,因此 prompt 或 schema 的修改只变动一行已提交内容([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **单元测试**(`pnpm run test`):vitest 运行 `packages|examples/*/tests/**/*.spec.ts`,与被测代码同目录。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 +- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 +- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试,调用真实提供方 API。包括 DeepSeek 模型以及各提供方特有的冒烟测试(各自依赖自己的密钥:`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等);缺少密钥时各套件自动跳过,keyless CI 保持绿色([真实 API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 +- **快照测试**(`pnpm run test:snapshot`):启动真实示例子进程,在无密钥环境下回放录制的会话,将归一化的 stdout 与重新持久化的日志与已提交的 golden 文件做 diff([快照 RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md))。当模型 transcript(文本记录)需要变更时使用 `pnpm run test:snapshot:record`;当已提交的 transcript 仍是正确的 mock LLM(大语言模型)输入、只需无密钥重写回放 golden 时使用 `pnpm run test:snapshot:refresh`。请审查 golden diff。系统提示词/工具 schema 内容由**一个**场景(`text-turn`)固定,其余 fixture(测试前置数据)中以 token 化形式引用,因此 prompt 或 schema 的修改只影响一行已提交内容([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 ## 带密钥策略:推理在这里很便宜 -我们是 DeepSeek:不要吝惜真实 API 测试。无密钥测试证明管道通了;只有带密钥运行才能证明 agent 对接真实模型时能正常工作。多写:文件写入 prompt、多轮对话、工具调用、流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条真实 prompt、检查外部世界的状态。它们能捕获「单元测试全绿、产品却坏了」这类 mock 在结构上无法发现的问题([事后分析 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过机制的存在仅仅是为了不阻塞无密钥 CI 和无密钥贡献者,它不是成本信号。每个示例都附带一个无密钥冒烟测试,以及(除非本身就不需要密钥)一个带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 +我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明管道通畅;只有带密钥运行才能证明 agent(智能体)在真实模型面前能正常工作。请大量编写:文件写入 prompt、多轮次对话、工具调用、流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条真实 prompt、检查外部世界的状态。它们能捕获「单元测试全绿、产品却坏了」这一类 mock 在结构上无法发现的问题([事后分析 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过机制的存在仅仅是为了不阻塞无密钥的 CI 和无密钥的贡献者,它不是成本信号。每个示例都附带一个 keyless 冒烟测试,并且——除非本身就不需要密钥——还附带一个带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 ## 优先使用真实实现而非 mock -只 mock 真正昂贵或不确定的边界(LLM 适配器、网络、时钟);下游一切保持真实。手写的替身只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言——两者会漂移,而测试仍然绿着。示例:bridge 工具调用测试运行脚本化的 mock 模型,但使用真实的 tool + 真实的执行器(`makeBridgeHarness({ withBash: true })` 接入 `dsh-bash-local` + `dsh-tool-bash`,执行真正的 `echo`)。 +只在真正昂贵或不确定的边界处 mock(LLM 适配器、网络、时钟);下游一切保持真实。手写的替身只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言——两者会漂移,而测试继续绿着。例如:bridge 工具调用测试运行脚本化的 mock 模型,但使用真实的 tool + 真实的执行器(`makeBridgeHarness({ withBash: true })` 接入 `dsh-bash-local` + `dsh-tool-bash` 并执行真正的 `echo`)。 ## 验证外部世界,而非自我报告 -e2e 断言应重新运行命令或从外部重新读取文件;仅对 agent 自身输出做关键词探测会让作弊的 agent 通过。断言未改动的文件字节相同。e2e 测试拥有自己的资源:在测试中创建 harness,在 `afterEach` 中 dispose(即使失败/重试/超时);共享 fixture 放在普通的 `tests/harness.ts` 中,绝不放在另一个 `*.e2e.ts` 里(import 一个 spec 会重新注册其 `describe`,导致真实 API 调用重复)。 +e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身输出做关键词探测会让作弊的 agent 通过。断言未修改的文件逐字节一致。e2e 测试自行管理资源:在测试中创建 harness,在 `afterEach` 中 dispose(即使失败/重试/超时也要释放);共享 fixture 放在普通的 `tests/harness.ts` 中,绝不放在另一个 `*.e2e.ts` 中(导入一个 spec 会重新注册其 `describe`,导致真实 API 调用重复执行)。 ## 测试真实入口路径 -- 产品可见的插件需要一个非单元的真实组合测试。手工搭建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部/不确定边界,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 混入默认交付。 -- 一个守卫只有在回归真正让它失败时才算守卫。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿——需要加一个显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、还原。 -- 「真实入口路径」指已发布的产物:package 的 `bin` 指向构建出的 `lib/bin.js`,在原生 `node` 下运行;tsx 会掩盖竞态、模块解析问题以及静默以 0 退出的加载失败。同理适用于构建后 package 在运行时解析的任何非 index 运行时入口(worker-thread 运行时的兄弟文件 `lib/worker.cjs`)。保持构建产物冒烟测试绿色(`packages/ui/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零退出。 -- 从临时 cwd spawn 示例的 e2e 测试需要设置 `TSX_TSCONFIG_PATH` 指向仓库根 tsconfig,否则会静默回退到陈旧的构建 `lib/`([examples/AGENTS.md](../examples/AGENTS.md))。 +- 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部/不确定边界,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 +- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 +- 「真实入口路径」指已发布的产物:package 的 `bin` 指向在普通 `node` 下运行的构建产物 `lib/bin.js`,tsx 会掩盖问题(竞态、模块解析、吞掉的加载失败以 exit 0 退出)。同样适用于构建后的 package 在运行时解析的任何非 index 运行时入口(worker-thread 运行时的兄弟文件 `lib/worker.cjs`)。保持构建产物冒烟测试绿色(`packages/ui/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零退出码退出。 +- 从临时 cwd spawn 示例的 e2e 测试需要设置 `TSX_TSCONFIG_PATH` 为仓库根目录的 tsconfig,否则会静默回退到陈旧的构建产物 `lib/`([examples/AGENTS.md](../examples/AGENTS.md))。 ## 何时需要快照测试 -任何影响编辑器侧 transcript 或端到端 agent 用户体验的变更——ACP bridge、agent loop(智能体循环)的可观测输出、工具呈现——都应在所属示例的快照套件中添加或更新场景(`examples/<name>/tests/snapshots/`,基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表;`examples/acp-agent` 是主套件),或在 PR 中说明为何不适用。新的能力 seam、生命周期形态或 transcript 表面在计划阶段就要标明各层的覆盖方式,并验证 harness 能表达它——harness 的缺口是排期工作,不是构建中途的意外。 +任何影响编辑器侧 transcript 或端到端 agent UX 的变更——ACP bridge、agent loop(智能体循环)的可观测输出、工具呈现——都需要在所属示例的快照套件中添加或更新场景(`examples/<name>/tests/snapshots/`,基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表;`examples/acp-agent` 是主套件),或在 PR 中说明为何不适用。新的能力 seam、生命周期形态或 transcript 表面在计划阶段就要列出各层级的覆盖方案,并验证 harness 能够表达它——harness 的缺口是排期工作,不是构建中途的意外。 From 6bf93b8e6af24ddf543660f65e940b9ee07f11ba Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:04:54 -0700 Subject: [PATCH 005/113] chore: drop stray embedded worktree from index --- .worktrees/i18n-prompt-sync | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .worktrees/i18n-prompt-sync diff --git a/.worktrees/i18n-prompt-sync b/.worktrees/i18n-prompt-sync deleted file mode 160000 index 7d822f3be7..0000000000 --- a/.worktrees/i18n-prompt-sync +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7d822f3be7523bb5b6f3a874eed476b05ec545fd From e38955b2fa829cd2b8d8ef2e686731330079115c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:06:01 -0700 Subject: [PATCH 006/113] docs(i18n): re-translate cds/postmortem batch with the prompt-v4 pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 22 篇(core-data-structures 18、postmortem 3、rfc/README)译文按 v4 基线重出;机械核对零异常;rfc/README.zh 页内锚点按门禁规则改回 英文侧锚名。 --- docs/core-data-structures/approval.i18n.yaml | 2 +- docs/core-data-structures/approval.zh.md | 16 ++--- docs/core-data-structures/bash.i18n.yaml | 2 +- docs/core-data-structures/bash.zh.md | 24 +++---- .../code-runtime.i18n.yaml | 2 +- docs/core-data-structures/code-runtime.zh.md | 14 ++-- .../core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 18 ++--- .../core-data-structures/filesystem.i18n.yaml | 2 +- docs/core-data-structures/filesystem.zh.md | 32 ++++----- .../llm-streaming.i18n.yaml | 2 +- docs/core-data-structures/llm-streaming.zh.md | 20 +++--- .../persistence.i18n.yaml | 2 +- docs/core-data-structures/persistence.zh.md | 20 +++--- docs/core-data-structures/sandbox.i18n.yaml | 2 +- docs/core-data-structures/sandbox.zh.md | 18 ++--- docs/core-data-structures/scope.i18n.yaml | 2 +- docs/core-data-structures/scope.zh.md | 8 +-- .../session-query.i18n.yaml | 2 +- docs/core-data-structures/session-query.zh.md | 8 +-- docs/core-data-structures/session.i18n.yaml | 2 +- docs/core-data-structures/session.zh.md | 44 ++++++------- docs/core-data-structures/skills.i18n.yaml | 2 +- docs/core-data-structures/skills.zh.md | 26 ++++---- docs/core-data-structures/subagent.i18n.yaml | 2 +- docs/core-data-structures/subagent.zh.md | 28 ++++---- .../system-prompt.i18n.yaml | 2 +- docs/core-data-structures/system-prompt.zh.md | 10 +-- docs/core-data-structures/tools.i18n.yaml | 2 +- docs/core-data-structures/tools.zh.md | 44 ++++++------- .../user-interaction.i18n.yaml | 2 +- .../user-interaction.zh.md | 12 ++-- docs/core-data-structures/web.i18n.yaml | 2 +- docs/core-data-structures/web.zh.md | 22 +++---- docs/core-data-structures/workflow.i18n.yaml | 2 +- docs/core-data-structures/workflow.zh.md | 16 ++--- ...-acp-default-export-drops-inject.i18n.yaml | 2 +- ...0001-acp-default-export-drops-inject.zh.md | 66 +++++++++---------- ...ession-disabled-filesystem-tools.i18n.yaml | 2 +- ...expression-disabled-filesystem-tools.zh.md | 34 +++++----- docs/postmortem/README.i18n.yaml | 2 +- docs/postmortem/README.zh.md | 12 ++-- docs/rfc/README.i18n.yaml | 2 +- docs/rfc/README.zh.md | 62 ++++++++--------- 44 files changed, 299 insertions(+), 299 deletions(-) diff --git a/docs/core-data-structures/approval.i18n.yaml b/docs/core-data-structures/approval.i18n.yaml index f51b3ab961..9a644ee85a 100644 --- a/docs/core-data-structures/approval.i18n.yaml +++ b/docs/core-data-structures/approval.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write approval.md: 772582955145092f3d483c297f7704b2b8506375 -approval.zh.md: dc45e1c6969099a2b285b4da071153510356acce +approval.zh.md: 706613b84784d81cf112622059c755e815c23f16 diff --git a/docs/core-data-structures/approval.zh.md b/docs/core-data-structures/approval.zh.md index dc45e1c696..706613b847 100644 --- a/docs/core-data-structures/approval.zh.md +++ b/docs/core-data-structures/approval.zh.md @@ -2,19 +2,19 @@ [English](approval.md) | 中文 -[dsh-user-approval](../../packages/ui/user-approval) 的用户审批 seam 回答一个问题:这个具体操作是否可以继续?它拥有共享的请求/结果词汇、`ctx.approval` 分发服务、`approval/request` 应答者 waterfall(瀑布式事件)、仅记录日志的审计事件对,以及按会话的 `ask`/`never` 策略。UI 通道(如 [dsh-acp](../../packages/ui/acp))提供应答者;调用方(如 [dsh-tools](../../packages/core/tools) 和 [dsh-tool-bash](../../packages/bash/tool-bash))消费封闭的结果,并在结果不是 `allowed-once` 时默认拒绝。 +[dsh-user-approval](../../packages/ui/user-approval) 的用户审批 seam 回答一个问题:这个具体操作是否可以继续?它拥有共享的请求/结果词汇、`ctx.approval` 分发服务、`approval/request` 应答者 waterfall(瀑布式事件)、仅记录日志的审计事件对,以及按会话的 `ask`/`never` 策略。UI 通道如 [dsh-acp](../../packages/ui/acp) 提供应答者;调用方如 [dsh-tools](../../packages/core/tools) 和 [dsh-tool-bash](../../packages/bash/tool-bash) 消费闭合的结果,除非结果为 `allowed-once`,否则一律拒绝。 源码:[`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approval/src/index.ts) ## 标识与结果 -每个请求获得一个新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时防止审批 id 与工具调用、会话或 agent id 混用。 +每个请求获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 和 `approval/decided` 审计事件配对,同时确保审批 id 不会与 tool-call、session 或 agent id 混用。 ```ts type-equiv type ApprovalRequestId = Branded<'ApprovalRequestId'> ``` -`ApprovalOutcome` 是封闭的,且默认拒绝。`allowed-once` 仅授权被询问的那个操作;调用方在遇到 `rejected`、`cancelled` 和 `unavailable` 时一律拒绝。缺失的、不拥有该请求的、抛出异常的或不符合规范的应答者会产生 `unavailable`,而不是放行。 +`ApprovalOutcome` 是闭合的,且默认拒绝。`allowed-once` 仅授权所询问的那一个操作;调用方对 `rejected`、`cancelled` 和 `unavailable` 均执行拒绝。缺失、无所有权、抛异常或不合规的应答者会产生 `unavailable`,而非放行。 ```ts type-equiv type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' @@ -22,17 +22,17 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ## 按会话策略 -`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,其无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值取会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 +`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 ```ts type-equiv type ApprovalPolicy = 'ask' | 'never' ``` -提示词段落会声明 `never` 的确定性行为,并用服务自有的标记记录当前策略。重启后,pre-step 叙述器从已记录的请求头中读取该标记;它不从部署 persona 行文中推断状态。ACP 中空闲时的策略切换会被桥接层持有到下一次 `turn/start`,因为审批审计事件和策略事件必须保持在轮次内,以确保持久回放的正确性。 +提示词段落会声明 `never` 的确定性行为,并以服务自有的标记记录当前策略。重启后,步骤前叙述器从已记录的请求头中读取该标记,而非从部署 persona 行文中推断状态。ACP 空闲切换会在 bridge 中保持,直到下一个 `turn/start`,因为审批审计和策略事件必须保持在轮次内,以确保持久回放的正确性。 ## 审批请求 -`ApprovalRequest` 足够精确地标识 agent 和工具操作,以便路由和审计该问题。它有意省略工具参数:应答者通过 `callId` 将提示附加到已流式输出的工具调用上,而不是渲染可能漂移的第二份副本。 +`ApprovalRequest` 以足够精确的方式标识 agent 和工具操作,以便路由和审计该问题。它有意省略工具参数:应答者通过 `callId` 将提示附加到已流式输出的工具调用上,而非渲染一份可能漂移的副本。 ```ts type-equiv interface ApprovalRequest { @@ -61,6 +61,6 @@ interface ApprovalRequest { ## 分发与审计 -`ctx.approval.request(req)` 要求发起请求的会话处于一个打开的轮次内。它追加 `approval/asked`,获取一个结果,追加匹配的 `approval/decided`,然后以该结果 resolve。`never` 策略在服务内部、waterfall 分发之前就已强制执行,因此即使后来用 `prepend` 注册的应答者也无法绕过它。应答者在拥有该请求时返回结果,否则调用 `next()` 委托;第一个应答占据唯一的决策槽位。 +`ctx.approval.request(req)` 要求发起请求的会话处于一个打开的轮次内。它追加 `approval/asked`,获取一个结果,追加对应的 `approval/decided`,然后以该结果 resolve。`never` 策略在服务内部、waterfall 分发之前强制执行,因此即使后来以 `prepend` 注册的应答者也无法绕过它。应答者在拥有该请求时返回结果,否则调用 `next()` 委托;第一个应答占据唯一的决策槽位。 -审计事件仅记录日志,不进入模型 transcript(文本记录)。模型可见的行为是调用方派生的工具结果,而请求头记录的是模型实际看到的提示词策略。服务 dispose(资源释放)时会同时移除其提示词段落和 pre-step 叙述器;应答者监听器独立地通过 effect 绑定到其所属插件。 +审计事件仅写入日志,不进入模型 transcript(文本记录)。模型可见的行为是调用方派生的工具结果,而请求头记录的是模型实际看到的提示词策略。服务 dispose(资源释放)时会一并移除其提示词段落和步骤前叙述器;应答者监听器独立地通过 effect 绑定到其所属插件。 diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 323300cc4e..eba38e8f77 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write bash.md: 7b5c779b832ef5be6591e626980f7f22db54f239 -bash.zh.md: 519a7bf973fdf9fd9d7c4be2cc6ebf77e576135d +bash.zh.md: 8a209b4853e44d1846460c41f4d5b9a43082a65b diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 519a7bf973..8a209b4853 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,13 +2,13 @@ [English](bash.md) | 中文 -Bash 执行 seam:典型的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) 示例,拆分为三个包(package):接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local),本地子进程)、消费方([dsh-tool-bash](../../packages/bash/tool-bash),`bash`/`bash_output`/`bash_kill` 工具 schema)。Bash 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。沙箱化、容器化或远程后端只需作为兄弟包实现同一接口。 +Bash 执行 seam:典型的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) 示例,拆分为三个包(package):接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local),本地子进程)和消费方([dsh-tool-bash](../../packages/bash/tool-bash),`bash`/`bash_output`/`bash_kill` 工具 schema)。Bash 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。沙箱化、容器化或远程后端是实现同一接口的兄弟包。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## 请求与规格:`resolve()` 拆分 -该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs` 可选,由配置填充)与**执行器实际执行的完全解析规格**(这些字段为必填)分开。工具层在二者之间调用 `ctx.bash.resolve(request)`。这是本仓库「包边界处显式优于隐式」规则的具体体现:读到一个 `BashExecSpec` 的人永远不必猜测工作目录从何而来。 +该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs` 可选,由配置填充)与**执行器实际执行的完全解析规格**(这些字段为必填)分离。工具层在二者之间调用 `ctx.bash.resolve(request)`。这是本仓库「在包边界处显式优于隐式」规则的具体体现:阅读 `BashExecSpec` 的人永远不会疑惑工作目录从何而来。 ```ts type-equiv interface BashExecRequest { @@ -108,15 +108,15 @@ interface BashExecSpec { } ``` -`owner` token 是隔离键:执行器存储它但从不解释它(访问策略是消费方的职责),因此一个 agent 启动的后台任务不会被跨会话读取。必填但可空的字段设计使得遗忘 owner 会表现为一个可见的 `undefined`,而非一个静默无主的任务。 +`owner` token 是隔离键:执行器存储它但从不解释它(访问策略是消费方的职责),因此一个 agent 启动的后台任务不会被跨会话读取。必填但可空的字段使遗忘的 owner 成为一个可见的 `undefined`,而非一个静默无主的任务。 -受信的进程内插件使用 `stdin` 和 `env` 传递钩子载荷和钩子专用变量。面向模型的 bash 工具从其命名 schema 字段构造请求,不暴露这两个输入,因为 shell 语法已提供等价能力;测试会防止未来出现 `...args` 展开。这是请求形状纪律,而非安全边界:`dsh-bash-local` 无论这些字段如何都会清洗环境凭证,然后叠加调用方已持有的显式值。详见 [bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 +受信的进程内插件使用 `stdin` 和 `env` 传递钩子载荷与钩子专用变量。面向模型的 bash 工具从其命名的 schema 字段构造请求,不暴露这两个输入,因为 shell 语法本身已提供等价能力;测试防止未来出现 `...args` 展开。这是请求形状的纪律约束,而非安全边界:`dsh-bash-local` 无论这些字段如何都会清洗环境凭证,然后叠加调用方已持有的显式值。见 [bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 -该 seam 处理的两个 id 都是[品牌化](core.md)的(零成本 `string` 品牌,与 `SessionId`/`AgentId` 同一套机制):`BashTaskId`(被追踪的后台任务,由本地执行器生成 `bash-N`)和 `OwnerToken`(不透明的隔离键)。`OwnerToken` 刻意是与 `SessionId` **不同**的品牌,而非别名:bash seam 是一个能力 seam,它不得知道 owner token *意味着什么*,因此从不导入 `dsh-session` 的词汇。将所属 agent 的 `SessionId` 转换为 `OwnerToken` 的唯一边界是 `dsh-tool-bash` 消费方。对两者都做品牌化,可以防止裸 `string`(或在需要 `OwnerToken` 的位置传入 `BashTaskId`,反之亦然)在面向模型的 `task_id` 路径上通过类型检查。 +该 seam 处理的两个 id 都是[品牌化的](core.md)(零成本 `string` 品牌,与 `SessionId`/`AgentId` 相同的机制):`BashTaskId`(被跟踪的后台任务,由本地执行器生成 `bash-N`)和 `OwnerToken`(不透明的隔离键)。`OwnerToken` 刻意是一个与 `SessionId` **不同**的品牌,而非别名:bash seam 是一个能力 seam,不得知道 owner token *意味着*什么,因此它从不导入 `dsh-session` 的词汇。`dsh-tool-bash` 消费方是唯一将拥有者 agent 的 `SessionId` 转换为 `OwnerToken` 的边界。对两者施加品牌化,可以防止裸 `string`(或在需要 `OwnerToken` 的地方传入 `BashTaskId`,反之亦然)在面向模型的 `task_id` 路径上通过类型检查。 ## 前台运行:`BashRunResult` -一次已完成(或被终止)的前台运行的结果。正交的结果**独立报告**:一个进程可以既超时又以 exit 0 退出(因为它捕获了信号),因此 `timedOut`、`aborted`、`signal` 和 `exitCode` 各自独立为一个字段;调用方永远不会把一次被截断的运行误读为干净的成功。 +一次已完成(或被终止)的前台运行的结果。正交的结果**独立报告**:一个进程可以同时超时并以退出码 0 退出(因为它捕获了信号),因此 `timedOut`、`aborted`、`signal` 和 `exitCode` 各自独立为一个字段;调用方永远不会把一次被截断的运行误读为干净的成功。 ```ts type-equiv interface BashRunResult { @@ -156,9 +156,9 @@ interface CollectedOutput { ## 文件沙箱:`BashSandboxInfo` -消费沙箱的执行器(`dsh-bash-sandbox`)通过 `BashExecutor.sandboxMode` 暴露其配置的回退模式。工具层折叠每个 agent 会话的持久 `bash/sandbox-mode` 覆盖,将生效模式盖章到请求上,并可能为一次用户批准的严格更宽调用替换它。工具层刻意不声明当前模式,也不叙述切换过程;拒绝结果会指明该命令实际运行时所处的模式。模式/强制词汇由 [`@deepseek-ai/dsh-sandbox` seam](sandbox.md) 拥有并编目,其提供方包装执行器的 argv;模式仅管辖文件效果,不管网络或进程可见性。 +消费沙箱的执行器(`dsh-bash-sandbox`)通过 `BashExecutor.sandboxMode` 暴露其配置的回退模式。工具层折叠每个 agent 会话的持久 `bash/sandbox-mode` 覆盖,将生效模式印到请求上,并可为一次用户批准的严格更宽调用替换它。它刻意既不声明当前模式也不叙述切换过程;拒绝结果会指明该命令实际运行时所处的模式。模式/执行词汇由 [`@deepseek-ai/dsh-sandbox` seam](sandbox.md) 拥有并编目,其提供方包装执行器的 argv;模式仅管控文件效果,不涉及网络或进程可见性。 -沙箱化运行始终在 `BashRunResult.sandbox` 上报告其执行时的事实:`denied` 是执行器对「失败由沙箱引起」的保守分类(一次失败退出且 stderr 带有文件系统权限签名——从不是干净退出或信号终止),从收集的 stderr 尾部读取;`enforcement` 报告所选后端对该模式文件效果的治理完整度(`SandboxEnforcement = 'full' | 'partial'`——当较旧的 Landlock ABI 仅治理所请求访问的子集时为 `partial`;`danger-full-access` 下不存在,因为什么都没被限制);`runnerFailed` 标记与拒绝相反的情况——沙箱 runner 本身失败,命令从未运行(仅在已结算的后台任务上盖章;前台运行通过抛出 `SANDBOX_UNAVAILABLE` 错误暴露同一状况): +沙箱化运行始终在 `BashRunResult.sandbox` 上报告其执行时的事实:`denied` 是执行器对失败的保守分类——判定为沙箱导致(退出失败且 stderr 携带文件系统权限特征——从不是干净退出或信号终止),从收集到的 stderr 尾部读取;`enforcement` 报告所选后端对该模式文件效果的管控完整程度(`SandboxEnforcement = 'full' | 'partial'`:`partial` 表示较旧的 Landlock ABI 仅管控所请求访问的子集;`danger-full-access` 下不存在此字段,因为没有任何限制);`runnerFailed` 标记与拒绝相反的情况——沙箱运行器本身失败、命令从未执行(仅在已结算的后台任务上标记;前台运行通过抛出 `SANDBOX_UNAVAILABLE` 错误暴露同一状况): ```ts type-equiv interface BashSandboxInfo { @@ -195,11 +195,11 @@ interface BashSandboxInfo { } ``` -还有一个词汇完成整幅图景:`SANDBOX_UNAVAILABLE` 错误码(由 [sandbox seam](sandbox.md) 拥有)是 `ctx.sandbox` 提供方在受限模式没有可用后端时抛出的——执行器将其传播。所选 runner 拒绝其 profile 也会到达同一个快速失败的前台错误;已结算的后台任务则记录 `runnerFailed`。模型在结果中收到拒绝/runner 事实,仅在拒绝标记指明模式时才得知生效模式,并可通过 `sandbox_permissions` 加 `justification` 请求一次严格更宽的重试;`ctx.approval` 必须在任何执行之前批准该确切调用。完整的策略与切换设计见 [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md)。 +还有一个词汇完成整幅图景:`SANDBOX_UNAVAILABLE` 错误码(由 [sandbox seam](sandbox.md) 拥有)是 `ctx.sandbox` 提供方在受限模式没有可用后端时抛出的错误,执行器将其传播。所选运行器拒绝其 profile 时也触发同一快速失败的前台错误;已结算的后台任务则记录 `runnerFailed`。模型在结果中接收拒绝/运行器事实,仅在拒绝标记指明模式时才获知生效模式,并可通过 `sandbox_permissions` 加 `justification` 请求一次严格更宽的重试;`ctx.approval` 必须在任何执行之前批准该确切调用。完整的策略与切换设计见 [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md)。 ## 后台任务:`BashTask` -通过 `start()` 启动的长时间运行命令被追踪为 `BashTask`。`BashTaskStatus` 为 `'running' | 'completed' | 'killed'`;`done` 在底层进程关闭时 resolve,从不 reject。沙箱化执行器在任务结算后盖章 `sandbox`(分类针对已结算任务收集的 stderr 运行),因此该字段在运行中以及非沙箱化执行器下不存在。 +通过 `start()` 启动的长时间运行命令被跟踪为 `BashTask`。`BashTaskStatus` 为 `'running' | 'completed' | 'killed'`;`done` 在底层进程关闭时 resolve,从不 reject。沙箱化执行器在任务结算后标记 `sandbox`(分类针对已结算任务收集到的 stderr 运行),因此该字段在运行中以及非沙箱化执行器下不存在。 ```ts type-equiv interface BashTask { @@ -224,7 +224,7 @@ interface BashTask { } ``` -`readOutput()` 返回增量的 `BashTaskRead`:自上次读取以来产生的输出,附带一个 `lossy` 标志表示截断丢弃了未读字节: +`readOutput()` 返回增量的 `BashTaskRead`:自上次读取以来产生的输出,附带一个 `lossy` 标志指示截断是否丢弃了未读字节: ```ts type-equiv interface BashTaskRead { @@ -242,4 +242,4 @@ interface BashTaskRead { ## 服务 -`BashExecutor`(`ctx.bash`,抽象——定义于 [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts))镜像 `LlmService`/`LlmAdapter` 的拆分:`resolve`(请求→规格)、`run`(前台)、`start`(后台)、`get`/`ownerOf`/`list`/`readOutput`/`kill`,以及 `onTaskDone`(`BashTaskListener` 完成回调)。spawn 的命令获得一个**清洗后的 env**(丢弃 `*KEY*`/`*SECRET*`/`*TOKEN*`),溢出文件使用一个权限为 0700 的私有目录(随机文件名、仅所有者可打开)——模型输出永远拿不到宿主环境或可预测路径。提供这一切的实现是 `dsh-bash-local`;调用它的面向模型的 `bash`/`bash_output`/`bash_kill` schema 位于 `dsh-tool-bash`(并通过[工具呈现词汇](tools.md#tool-presentation-ui-vocabulary)以终端形式展示)。 +`BashExecutor`(`ctx.bash`,抽象——定义于 [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts))遵循 `LlmService`/`LlmAdapter` 的拆分模式:`resolve`(请求→规格)、`run`(前台)、`start`(后台)、`get`/`ownerOf`/`list`/`readOutput`/`kill`,以及 `onTaskDone`(`BashTaskListener` 完成回调)。spawn 的命令获得一个**清洗后的 env**(丢弃 `*KEY*`/`*SECRET*`/`*TOKEN*`),溢出文件使用一个权限为 0700 的私有目录(随机文件名、仅所有者可打开)。模型输出永远不会获得环境变量或可预测路径。提供这一切的实现是 `dsh-bash-local`;调用它的面向模型的 `bash`/`bash_output`/`bash_kill` schema 位于 `dsh-tool-bash`(并通过[工具展示词汇](tools.md#tool-presentation-ui-vocabulary)作为终端呈现)。 diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index b2c8f97ae1..7c9ef2a7a3 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write code-runtime.md: 28152947d0853fb10228c472ca3e121e77b7b598 -code-runtime.zh.md: f12816fd5392f1efff3a1faeee232fb004142f37 +code-runtime.zh.md: 8270d12e7cce8c3b2443da9de268d95cf9d692d8 diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index f12816fd53..8270d12e7c 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -2,13 +2,13 @@ [English](code-runtime.md) | 中文 -代码执行 seam:一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)负责运行一段模型编写的程序,对接宿主提供的异步绑定,并报告程序打印和返回的内容。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。后端因执行基底和源语言而异,二者均为服务上的只读描述符;worker-thread 后端与工具注册表消费方(Code Mode)在 [Code Mode RFC](../rfc/implemented/feature/2026-06-15-code-mode.md) 中规定。 +代码执行 seam:一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)运行一段模型编写的程序,对接宿主提供的异步绑定,并报告程序的打印输出与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。后端因执行基底和源语言而异,二者都是服务上的只读描述符;worker-thread 后端与工具注册表消费方(Code Mode)在 [Code Mode RFC](../rfc/implemented/feature/2026-06-15-code-mode.md) 中定义。 源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) ## 运行:请求进,结果出 -`CodeRunRequest` 携带**运行时所需的全部信息**。按照「包(package)seam 处显式优于隐式」的规则,默认值(时间预算、输出上限)由实现的已校验配置提供,绝不是 `run()` 内部隐藏的 `??`: +`CodeRunRequest` 携带**运行时所需的一切**。按照「包(package)边界处显式优于隐式」的规则,默认值(时间预算、输出上限)来自实现的已校验配置,绝不是 `run()` 内部隐藏的 `??`: ```ts type-equiv interface CodeRunRequest { @@ -30,7 +30,7 @@ interface CodeRunRequest { } ``` -结果将错误报告为一个**字段**,而非 `run()` 的 rejection:报告程序失败是调用方的职责,不是异常路径(与 `BashExecutor.run` 的 resolve-on-failure 契约一致): +结果将错误报告为一个**字段**,而非 `run()` 的 rejection。报告失败的程序是调用方的职责,不走异常路径(与 `BashExecutor.run` 的 resolve-on-failure 契约一致): ```ts type-equiv interface CodeRunResult { @@ -50,7 +50,7 @@ interface CodeRunResult { ## 绑定:宿主函数作为程序全局变量 -每个 `CodeBindingNamespace` 在程序内部成为一个由异步可调用成员组成的全局对象(Code Mode 消费方传入一个:`tools`)。参数与解析值必须可 structured-clone:运行时可能跨序列化边界桥接调用。运行时将绑定名视为不可信输入(`__proto__` 是普通的 own property,绝不会产生原型碰撞): +每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(Code Mode 消费方传入一个:`tools`)。参数与返回值必须可 structured-clone(运行时可能跨序列化边界桥接调用),且运行时将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): ```ts type-equiv interface CodeBindingNamespace { @@ -67,9 +67,9 @@ type CodeBindingFunction = (args: unknown) => Promise<unknown> ## 捕获的输出与失败分类体系 -日志是按发出顺序排列的纯字符串。运行时捕获程序的 console 和流输出,但通道与 console 方法的元数据不属于 seam 的一部分,因为消费方只渲染文本。实现对聚合输出设上限,并在输出内标记截断。 +日志是按发出顺序排列的纯字符串。运行时捕获程序的 console 与流输出,但通道和 console 方法的元数据不属于 seam 的一部分,因为消费方只渲染文本。实现对聚合输出设上限,并在输出内标记截断。 -失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者之一: +失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个: ```ts type-equiv interface CodeRunFailure { @@ -82,4 +82,4 @@ interface CodeRunFailure { ## 服务 -`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))是 `run(request)` 加两个只读描述符:`language`(程序必须使用的语言:`'typescript'` 是已知值;生成语言相关展示的消费方据此分支,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底:`'worker-thread'`、`'process'`、`'container'`;是诊断标签,**不是安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时达到静止状态:进行中的运行在 teardown 完成前被终止并 await。 +`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,`'typescript'` 是已知值;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),且 dispose(资源释放)至静默:进行中的运行在 teardown 完成前被终止并等待结束。 diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index a5dda25c68..a0a32275e6 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: e82cc103932ad05e68bc5311dec23c3f2c1a7ce4 -compaction.zh.md: 889cdba416767e90592359361fc0f65bcb2472c3 +compaction.zh.md: ad8b524a1984357b5bb911b59300a194407bdbb9 diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 889cdba416..ad8b524a19 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -1,28 +1,28 @@ -# 压缩 +# 上下文压缩 [English](compaction.md) | 中文 -压缩(compaction)seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),按 bash 模式拆分:接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(后端,如 [dsh-compact-basic](../../packages/compact/compact-basic))、消费方(一个 `/compact` 工具,暂缓)。压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。基于 tokenizer 或模板的后端是实现同一接口的兄弟包。与 bash 不同的是,该接口必然依赖 `dsh-session` 和 `dsh-llm`:它的动词定义在 `Session` 之上,输出是 `ContentBlock` 词汇(见[压缩能力 seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md))。 +上下文压缩(context compaction)的 seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),按 bash 式拆分:接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(后端,如 [dsh-compact-basic](../../packages/compact/compact-basic))、消费方(一个 `/compact` 工具,暂缓实现)。上下文压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处,而非 [core.md](core.md)。基于 tokenizer 或模板的后端是实现同一接口的兄弟包。与 bash 不同的是,该接口必然依赖 `dsh-session` 和 `dsh-llm`:它的动词定义在 `Session` 之上,输出使用 `ContentBlock` 词汇(见[上下文压缩能力 seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md))。 源码:[`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) ## `compact/*` 会话事件 -压缩通过声明合并为 [`SessionEventMap`](session.md) 扩展了三种事件类型。三者均为**仅日志**事件:它们记录压缩锁及其来源信息,永远不进入 surface。`SurfaceEventType` 被刻意**不**扩展(只有产生消息的事件才到达模型),因此摘要本身搭载在一条单独的 `user/message` 上,带有 `surfaceOp: { op: 'replace', start, end }`——唯一的 surface 变更。关于为何复用 `user/message` 是诚实的做法而非变通手段,见 RFC。 +上下文压缩通过声明合并为 [`SessionEventMap`](session.md) 扩展了三种事件类型。三者均为**仅日志**事件:它们记录压缩锁及其来源信息,永远不进入 surface。`SurfaceEventType` 被刻意**不**扩展(只有产生消息的事件才到达模型),因此摘要本身搭载在一条独立的 `user/message` 上,带有 `surfaceOp: { op: 'replace', start, end }`——唯一的 surface 变更。关于为何复用 `user/message` 是诚实的做法而非权宜之计,见 RFC。 | 事件 | 载荷 | 作用 | |---|---|---| | `compact/start` | `{ turn }` | 获取日志记录的锁 | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, model, maxTokens? }` | 来源信息:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数量,以及摘要调用的信封(`model`,加上生效时的生成上限)——记录下来以便从日志 + 代码重建一次性请求(可重建性 RFC) | -| `compact/end` | `{ turn, error? }` | 释放锁(摘要生成抛出异常时设置 `error`) | +| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, model, maxTokens? }` | 来源信息:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq,是位置跨度而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算的 token 数量,以及摘要调用的信封(`model`,加上生效时的生成上限)。记录这些信息使得单次请求可从日志加代码重建(reconstructability RFC) | +| `compact/end` | `{ turn, error? }` | 释放锁(摘要调用抛出异常时设置 `error`) | -锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、落入 `compact/summary` 来源记录和 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会变成一个可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而不是一个虚假声称压缩已完成的 `compact/end`。 +锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 这些变体在 `declare module '@deepseek-ai/dsh-session'` 块内合并,因此——与其他子页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 ## `CompactionResult` -一次成功的压缩返回给调用方的内容:三个追加的 `compact/*` 事件的 seq、摘要块,以及被遮蔽的范围/seq 加上估算 token 数量。 +一次成功的压缩返回给调用方的内容:三个追加的 `compact/*` 事件的 seq、摘要块,以及被遮蔽的范围/seq 和估算的 token 数量。 ```ts type-equiv interface CompactionResult { @@ -52,6 +52,6 @@ interface CompactionResult { ## 服务 -`CompactService` 暴露 `compactIfNeeded(...)` 用于压力触发的压缩(不需要压缩时返回 `null`),以及 `compactRegion(...)` 用于对显式的 surface 闭区间执行压缩。pre-step 调用方提供 agent、完整提示词、会话前缀和 abort signal;实现必须将该 signal 转发给摘要生成。估算、保留策略、事件排序和摘要生成均为后端策略。 +`CompactService` 暴露 `compactIfNeeded(...)` 用于压力触发的压缩(不需要压缩时返回 `null`),以及 `compactRegion(...)` 用于对显式的闭区间 surface 范围进行压缩。pre-step 调用方提供 agent、完整 prompt、会话前缀和 abort signal;实现必须将该 signal 转发给摘要生成。估算、保留策略、事件排序与摘要生成均为后端策略。 -自动压缩在串行的 `agent/pre-step` 时运行,位于步骤和请求推导之前,因此它可以替换 surface 节点,同时将 trace 事件保持在步骤之外。区域边界保留工具调用/结果的配对,但不保留完整轮次,允许一个超大轮次中较早关闭的步骤被压缩。保留策略与失败处理的细节由 `dsh-compact-basic` 负责。 +自动压缩在串行的 `agent/pre-step` 时运行,位于步骤和请求推导之前,因此可以在替换 surface 节点的同时将 trace 事件保持在步骤之外。区域边界保持工具调用/结果配对,但不保持完整轮次,允许一个超大轮次中已关闭的早期步骤被压缩。保留策略与失败处理的细节由 `dsh-compact-basic` 负责。 diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 760bf4eda7..4e3d400339 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write filesystem.md: 8bdc2323a0bf63588e01520926f093538fee4912 -filesystem.zh.md: 93ca9b26e054cadb40382391404573c95a1c8d05 +filesystem.zh.md: 1b636dd35e3c614d87973c617ea052058995e0e0 diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 93ca9b26e0..1b636dd35e 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -2,15 +2,15 @@ [English](filesystem.md) | 中文 -可选的文件系统能力由四部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选版本守卫的原子文本操作,[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端,[dsh-fs-policy](../../packages/fs/fs-policy) 通过事件(而非服务)添加观测状态与新鲜度规则,[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop 主干之外;替换后端不会改变策略或工具 schema。 +可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选版本守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 通过事件(而非服务)添加观测状态与新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。 -该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam(`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个在此之上*添加*策略的插件,通过裁决 `fs/*` waterfall(瀑布式事件)实现;移除它只会留下裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署预期同时加载 `dsh-fs-policy`,使默认行为为先读后写/编辑。 +该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam(`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个插件,通过裁决 `fs/*` waterfall(瀑布式事件)在上层*叠加*策略;移除它只会暴露裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 提供方源码:[`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) 与 [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)。策略源码:[`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts)。读取渲染源码:[`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts)。 ## 目标标识与元数据(提供方 seam) -每个操作首先将用户提供的路径解析为一个不透明的后端目标。消费方可以展示 `displayPath`,但不得解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。 +每个操作首先将用户提供的路径解析为不透明的后端目标。消费方可以显示 `displayPath`,但禁止解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。 ```ts type-equiv interface FsTarget { @@ -19,7 +19,7 @@ interface FsTarget { } ``` -后端拥有文件版本 token:即 write/edit 所守卫的新鲜度 token。策略插件存储它们用于陈旧检查;消费方不解释其含义。两个 id 都是品牌化的不透明字符串。 +后端拥有文件版本 token,即 write/edit 所守卫的新鲜度 token。策略插件存储它们以进行陈旧检查;消费方不解释其内容。两个 id 都是品牌化的不透明字符串。 ```ts type-equiv type FsTargetKey = Branded<'FsTargetKey'> @@ -29,7 +29,7 @@ type FsTargetKey = Branded<'FsTargetKey'> type FsVersion = Branded<'FsVersion'> ``` -`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录/特殊文件,`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。 +`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录或特殊文件;`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。 ```ts type-equiv interface FsInfo { @@ -39,7 +39,7 @@ interface FsInfo { } ``` -`listDir` 以稳定的名称顺序返回直接子条目。每个条目携带子项的 basename、类型、已解析的目标,以及后端能廉价报告时的元数据。它不得读取文件内容,因此 `size` 仅适用于普通文件,`version` 来源于元数据。损坏或消失的子项可以作为 `other` 返回且不带元数据;列举或解析子项元数据时的权限或后端 I/O 失败会以 `FS_PERMISSION_DENIED` 或 `FS_IO_ERROR` 使整个列举失败。 +`listDir` 按稳定的名称顺序返回直接子条目。每个条目携带子项的 basename、类型、已解析目标,以及后端能报告时的廉价元数据。它禁止读取文件内容,因此 `size` 仅用于普通文件,`version` 来自元数据。已损坏或已消失的子项可以作为 `other` 返回且不带元数据;列出或解析子项元数据时的权限或后端 I/O 失败会以 `FS_PERMISSION_DENIED` 或 `FS_IO_ERROR` 使整个列表操作失败。 ```ts type-equiv interface FsDirEntry { @@ -53,7 +53,7 @@ interface FsDirEntry { ## 写入与编辑守卫(提供方 seam) -`writeText` 和 `editText` 都以可选方式接受版本守卫:省略即为无条件(裸提供方)变更,提供即为守卫。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 创建缺失的目标,若目标已存在则以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只携带两种守卫意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 +`writeText` 和 `editText` 的版本守卫都是可选的:省略它执行无条件(裸提供方)变更,提供它则启用守卫。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 ```ts type-equiv type FsWriteIntent = @@ -70,7 +70,7 @@ interface FsWriteOutcome { } ``` -`editText` 是提供方级别的变更,而非在别处组合的 `read` 加 `write`。守卫模式下,它在字面匹配之前先验证预期版本(因此对陈旧内容的编辑报 `FS_STALE_VERSION`,而非对更新内容的匹配失败);无守卫模式下,它编辑当前内容。无论哪种路径,它都应用替换并原子写入——将匹配、行尾处理、陈旧检查与原子替换保持在同一个变更临界区内——且目标缺失时两种路径都报 `FS_STALE_VERSION`。 +`editText` 是提供方级别的变更操作,而非在别处组合的 `read` 加 `write`。带守卫时,它在字面匹配之前先验证预期版本(因此对陈旧内容的编辑报 `FS_STALE_VERSION`,而非对更新内容的匹配失败);不带守卫时,它编辑当前内容。无论哪种路径,它都应用替换并原子写入——将匹配、行尾处理、陈旧检查和原子替换保持在一个变更临界区内——目标缺失时两条路径都报 `FS_STALE_VERSION`。 ```ts type-equiv interface FsEditRequest { @@ -90,13 +90,13 @@ interface FsEditOutcome { ## fs 策略事件(提供方 seam 词汇) -`dsh-fs` 拥有三个事件,由工具派发、策略插件监听,使发射方(`dsh-tool-fs`)和监听方(`dsh-fs-policy`)共享词汇而无需发射方依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/会话所有者结构。 +`dsh-fs` 拥有三个事件,由工具分发、策略插件监听,使发射方(`dsh-tool-fs`)与监听方(`dsh-fs-policy`)共享词汇,而发射方无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/session 所有者结构。 -`fs/write-intent` 和 `fs/edit-intent` 是**单槽决策 waterfall**:工具派发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全裁决而不调用 `next()`。该槽按注册顺序先到先得——策略插件占据该槽是部署约定,而非强制不变式。`fs/observed` 是一个即发即忘的记录事件,通过普通 `ctx.emit` 派发;其监听方必须是同步且仅有副作用的,因为工具不守卫该 emit——抛出异常的监听方会作为工具对一个已成功变更的 `isError` 结果暴露出来。生成的目录在 [events.md](../cordis-catalog/events.md) 展示确切签名。 +`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不守卫该 emit——抛异常的监听方会在一次已成功的变更上表现为工具的 `isError` 结果。生成的目录在 [events.md](../cordis-catalog/events.md) 中展示确切签名。 ## 执行上下文(策略插件) -策略插件只需要足够的执行上下文来从 `fs/*` 事件携带的不透明 `object` actor 中窄化出观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 透传,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 +策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入 tool、agent 或 session 包。 ```ts type-equiv interface FsPolicyExec { @@ -108,7 +108,7 @@ interface FsPolicyExec { ## 读取结果(消费方 / 读取渲染) -文本读取受行窗口、字节上限和后端限制约束。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接以 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取的执行器),而非策略插件。 +文本读取受行窗口、字节上限和后端限制约束。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接用 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 ```ts type-equiv interface FileReadOutcome { @@ -121,11 +121,11 @@ interface FileReadOutcome { ## 已观测文件状态(策略插件) -已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, { version }>>`。条目存在**当且仅当**所有者已读取、写入或编辑过该目标(每次成功都 emit `fs/observed`),因此条目的存在本身就是先前观测的记录——没有单独的 `hasRead` 标志,也没有视图区分。所有者从事件 actor 派生(通常是 `exec.agent.session`),被视为不透明且从不读取。成功的 read/write/edit 会刷新该所有者对应的已记录版本;dispose 时丢弃全部数据(HMR 安全)。 +已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, { version }>>`。当且仅当所有者已读取、写入或编辑过该目标时(每次成功都 emit `fs/observed`),条目才存在,因此其存在本身就是先前观测的记录——没有单独的 `hasRead` 标志,也没有视图区分。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。成功的 read/write/edit 会刷新该所有者对应的已记录版本;dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全)。 ## 错误分类体系(提供方 seam) -文件系统失败使用稳定的 `FsErrorCode` 字符串,由 `FsError`(`HarnessError`)携带。工具注册表在错误结果上保留 `{ name, code }`,使重试、权限和 UI 层无需解析文本即可分支。 +文件系统故障使用稳定的 `FsErrorCode` 字符串,由 `FsError`(`HarnessError`)携带。工具注册表在错误结果上保留 `{ name, code }`,使重试、权限和 UI 层可以按 code 分支而无需解析文本。 ```ts type-equiv type FsErrorCode = @@ -142,8 +142,8 @@ type FsErrorCode = | 'FS_ABORTED' ``` -`FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 和 `FS_IO_ERROR` 用于目录列举,分别区分目标存在但不是目录、列举被拒绝、以及意外的后端 I/O 失败。`FS_NOT_OBSERVED` 表示策略插件没有该所有者的先前观测记录(或 `createIfAbsent` 遇到了已存在的文件)。`FS_STALE_VERSION` 表示后端版本不再匹配已观测版本(或编辑遇到了缺失的目标)。新鲜度授权没有 partial/full 区分,因此不存在 `FS_PARTIAL_OBSERVATION`。 +`FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 和 `FS_IO_ERROR` 用于目录列表操作,分别区分目标存在但不是目录、列表被拒绝、以及意外的后端 I/O 故障。`FS_NOT_OBSERVED` 表示策略插件对该所有者没有先前观测记录(或 `createIfAbsent` 遇到了已存在的文件)。`FS_STALE_VERSION` 表示后端版本不再匹配已观测版本(或 edit 遇到了缺失的目标)。新鲜度授权没有 partial/full 区分,因此不存在 `FS_PARTIAL_OBSERVATION`。 ## 服务与插件 -`FileSystem`(`ctx.fs`,抽象)拥有提供方原语:`resolve`、`stat`、`readText`、`streamText`、`listDir`、`writeText` 和 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门添加策略的插件:它裁决 write/edit intent waterfall(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读/写/编辑,派发 waterfall,并 emit 记录事件。生成的接线目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,抽象)拥有提供方原语:`resolve`、`stat`、`readText`、`streamText`、`listDir`、`writeText` 和 `editText`。`dsh-fs-policy` 不注册任何服务——它是一个通过 `fs/*` 事件门控叠加策略的插件:它裁决 write/edit intent waterfall(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。生成的接线目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 中展示确切的 `ctx.fs` 签名。 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 4227258611..f9cd711351 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write llm-streaming.md: ffd276b4647be8d10afcab0fb3c3f6daad790d20 -llm-streaming.zh.md: 051d6f5d28059bf81ba38c348ea306c46e73d4e2 +llm-streaming.zh.md: 4cb87c9fe65e264f9ce2f39d6ea3b5c6183d5cc1 diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 051d6f5d28..4cb87c9fe6 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -8,7 +8,7 @@ ## `StreamChunk`:原始协议 -一次流式响应会交错多种类型的块(文本、推理、多个工具调用)。`index` 将每个 delta 关联到对应的块;`block-end` 携带完整组装好的 `ContentBlock`,消费方无需自行重新组装 delta。这是一个**封闭的**可辨识联合类型:对 `type` 的 `switch` 以 `assertNever` 结尾,因此新增变体会在每个必须处理它的消费方处触发编译错误。 +一个流式响应交错包含多种类型的块(文本、推理(reasoning)、多个工具调用)。`index` 将每个 delta 关联到其所属块;`block-end` 携带完整组装好的 `ContentBlock`,消费方无需自行重新组装 delta。这是一个**封闭的**可辨识联合类型:对 `type` 的 `switch` 以 `assertNever` 结尾,因此新增变体会在每个必须处理它的消费方处触发编译错误。 ```ts type-equiv type StreamChunk = @@ -23,18 +23,18 @@ type StreamChunk = ## 适配器契约 -每个适配器必须遵守以下规则,每个消费方可以依赖它们: +每个适配器**必须**遵守以下规则,每个消费方可以依赖它们: - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 -- **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化。 -- **两条认可的错误路径。** 失败可以从 `stream()` 抛出异常(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted'}` 结束流(提供方带内错误,适用于无法在流中途抛出异常的适配器)。消费方必须同时处理*两种*情况。agent loop(智能体循环)将 finish-error/aborted 转化为轮次错误,绝不会为失败的步骤记录一条正常完成的 assistant 消息。 -- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试证明这一点(mock 服务器断言收到的 header,或库支持的适配器使用库的 header 钩子)。 +- **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 +- **两条许可的错误路径。** 失败可以从 `stream()` 中 THROW(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted'}` 结束流(提供方带内错误,适用于无法在流中途抛出异常的适配器)。消费方必须同时处理*两种*情况。agent loop(智能体循环)将 finish-error/aborted 转化为轮次错误,绝不会为失败的步骤记录一条正常完成的 assistant 消息。 +- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 -这份契约正是两个适配器作为有意配对存在的原因:`dsh-llm-deepseek`(手写的 fetch/SSE(Server-Sent Events))与 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 访问同一端点)。两套独立的内部实现共享一份契约,正是它将协议钉死的方式:库支持的适配器无法在流中途抛异常,因此它行使了手写适配器可能不会走到的 finish-chunk 错误路径。 +这份契约正是两个适配器作为刻意配对存在的原因:`dsh-llm-deepseek`(手写 fetch/SSE(Server-Sent Events))与 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 访问同一端点)。两套独立内部实现共享一份契约,正是将协议固定下来的方式:基于库的适配器无法在流中途抛出异常,因此它走通了手写适配器可能不会走到的 finish-chunk 错误路径。 ## `AppIdentity`:应用归属 -每个适配器向提供方发送的静态公开应用身份([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 仅将其映射为标准 `User-Agent` header;本契约有意不支持 OpenRouter 特有的应用归属 header。默认的 `APP_IDENTITY` 从包(package)的 manifest(元数据清单)获取版本号;每个字段都是公开的产品事实,不含密钥、路径、会话 id 或用户标识符,且没有任何逐请求的值可以影响这些字段。设计依据见 [强制 `User-Agent` 归属](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +每个适配器向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 仅将其映射为标准 `User-Agent` header;本契约有意不支持 OpenRouter 特有的应用归属 header。默认的 `APP_IDENTITY` 从 package manifest(元数据清单)获取版本号;每个字段都是公开的产品事实,不含密钥、路径、会话 id 或用户级标识符,且任何请求级信息都不得影响这些值。设计依据见 [Mandatory `User-Agent` attribution](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 ```ts type-equiv interface AppIdentity { @@ -60,13 +60,13 @@ interface TokenUsage { ## `BlockAssembler` -`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,负责将 `StreamChunk` 流折叠回 `ContentBlock` 序列与最终的 `Message`。agent loop 记录原始分片(保证回放保真度),同时将相同的分片送入 assembler;这样权威日志保留了 token 级别的细节,而派生的消息可以确定性地重建。需要组装结果但不想重新实现折叠逻辑的消费方使用它。 +`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,将 `StreamChunk` 流折叠回 `ContentBlock` 列表与最终的 `Message`。agent loop 记录原始分片(保证回放保真度),同时将相同的分片送入 assembler,因此权威日志保留了 token 级细节,而派生消息可确定性地重建。需要组装结果而不想重新实现折叠逻辑的消费方使用它。 ## seam -`LlmAdapter` 是提供方 seam:继承它、实现 `stream()`、通过 `ctx.llm.registerAdapter(models, adapter)` 注册。`block-start`/`block-end` 的 `index` 关联加上 assembler,意味着适配器只需发出格式正确的分片,块的重新组装不是各适配器自己的问题。消费方接口(`ctx.llm.stream()`)与 `llm/stream` waterfall(瀑布式事件)在 [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm) 中描述。 +`LlmAdapter` 是提供方 seam:继承它、实现 `stream()`、通过 `ctx.llm.registerAdapter(models, adapter)` 注册。`block-start`/`block-end` 的 `index` 关联加上 assembler 意味着适配器只需发出格式正确的分片,块重组不是各适配器需要操心的事。消费方接口(`ctx.llm.stream()`)与 `llm/stream` waterfall(瀑布式事件)在 [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm) 中描述。 -`ContentBlockType`(`index` 关联的块所携带的键集合)派生自 `ContentBlockMap`: +`ContentBlockType`(`index` 关联块所携带的键集合)派生自 `ContentBlockMap`: ```ts type-equiv interface ContentBlockMap { diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 9f69617b7f..3072063a43 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write persistence.md: ce7a21a5613da903a9bddd339e122bf9f899d2bd -persistence.zh.md: 07d54895ef59b99dca47142e3fde16e6d7d0d1b3 +persistence.zh.md: 33ddda66aacc21f847dfd45702b11b5381711cce diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 07d54895ef..33ddda66aa 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -2,21 +2,21 @@ [English](persistence.md) | 中文 -事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述该日志如何被持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一起存储的元数据头。日志所承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐一列出。 +事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐项列举。 -该 seam 是教科书式的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在既有的 `SessionEvent` 之上定义 create/append/load/list——**没有平行的持久化类型**——以及两个可互换的后端,它们通过同一套 `runPersistenceContract` 测试。见 [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md)。 +该 seam 是典型的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在已有的 `SessionEvent` 之上定义 create/append/load/list 操作,**没有并行的持久化类型**,以及两个可互换的后端,它们通过同一套 `runPersistenceContract` 测试。详见 [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md)。 ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件对其进行缓冲(write-behind),并在 agent loop 于每个轮次结束时触发的 `session/flush` 检查点处排空缓冲区。flush 使用 `ctx.parallel`(被 await):一个轮次的事件在下一个轮次开始前被持久提交,轮次边界即提交边界。flush 失败时通过 `agent/error` 和 logger 报告,而非作为会话事件(那样会落在提交边界之后),因此后端保留其缓冲事件等待下一次 flush。 +`session/event` 是一个*同步*通知;持久化插件对其进行缓冲(write-behind),并在 agent loop(智能体循环)于每个轮次结束时触发的 `session/flush` 检查点处排空缓冲区。flush 使用 `ctx.parallel`(被 await):一个轮次的事件在下一个轮次开始前已被持久提交,轮次边界即提交边界。flush 拒绝时通过 `agent/error` 和 logger 报告,而非作为会话事件(那样会落在提交边界之后),因此后端保留其缓冲事件等待下一次 flush。 ## 崩溃恢复保留被中断的轮次 -后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 而没有对应的 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次封闭不变式完好。`interrupted` 是唯一一个 agent loop 不会自行发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 +后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次闭合不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 ## `SessionHeader`:日志旁的元数据 -每个会话的元数据与事件日志**分开**存储:格式版本、cwd、血缘关系和 seed 边界属于存储关注点而非对话事件,因此它们不在 `SessionEventMap` 中,也不会进入 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。 +每个会话的元数据与事件日志**分开**存储:格式版本、cwd、血统与 seed 边界是存储层关注点而非对话事件,因此不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。 源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) @@ -51,7 +51,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时接受 `seed`(回放/fork 一个已有事件日志)和 `meta`(store 折叠进 `SessionHeader` 的存储级字段)。store 填充 `version`/`id` 并为 `createdAt` 设默认值;调用方提供经过校验的绝对路径 `cwd`、`parentSession` 血缘、`seedLength` seed 边界,以及仅在重建持久化会话时提供的原始 `createdAt` 以保留它。 +通过 store 创建 `Session` 时接受 `seed`(回放/fork 已有事件日志)和 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并默认 `createdAt`;调用方提供经过校验的绝对路径 `cwd`、`parentSession` 血统、`seedLength` seed 边界,以及仅在重建持久化会话时提供的原始 `createdAt` 以保留其值。 ```ts type-equiv interface CreateSessionOptions { @@ -78,13 +78,13 @@ interface CreateSessionOptions { } ``` -因此,回放/fork 是 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 是 `ctx.agents.resume({ resumeSessionId })`。 +因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 ## 后端 -两个后端实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 之上的 create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 真正与后端无关: +两者实现相同的抽象 `SessionPersistence`(在 `SessionEvent` 之上提供 create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 真正与后端无关: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**:每个会话一个仅追加的 JSONL 日志,具备崩溃安全的原子写入、上述中断轮次崩溃恢复,以及读取/回放路径。 -- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包括可选的 surface 元数据),因此没有需要保持同步的平行持久化 schema。 +- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 -多个后端共享同一个磁盘会话时,通过[共享持久化写协调器](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 +多个后端共享同一磁盘会话时,通过[共享持久化写协调器](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 67bc36207d..e68f9b2c75 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write sandbox.md: be8e3cd60681077ff5036915fd99520fe9685140 -sandbox.zh.md: 2e9e5aa9a65e636167e45900f169ed6da5219c24 +sandbox.zh.md: ca21c09e7f3d0756f78d7bd1581b9b4b03a43815 diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 2e9e5aa9a6..ca21c09e7f 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -2,25 +2,25 @@ [English](sandbox.md) | 中文 -[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将同世界子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 与远程执行是整体能力 seam 的兄弟实现,而非 `ctx.sandbox` 的提供方。 +[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将同世界子进程的 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 和远程执行是完整能力 seam 的兄弟实现,而非 `ctx.sandbox` 的提供方。 源码:[`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) -## 模式与强制 +## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝写入(必需的 `/dev/null` sink 除外);`workspace-write` 允许在工作区根目录与后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此词汇范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入(必需的 `/dev/null` 接收器除外);`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' ``` -只有前两种模式可以发送给提供方。`danger-full-access` 消费方直接 spawn 原始 argv,不调用 `ctx.sandbox`。 +只有前两种模式可以发送给提供方。`danger-full-access` 的消费方直接 spawn 原始 argv,不调用 `ctx.sandbox`。 ```ts type-equiv type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'> ``` -强制级别是一个报告事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控了一个子集,因此要求绝对承诺的消费方必须拒绝或向上暴露这一区别。 +强制执行程度是一个报告事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。 ```ts type-equiv type SandboxEnforcement = 'full' | 'partial' @@ -28,7 +28,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -策略在每次调用时完全解析并随调用携带。这使得并发消费方和一次性升级重试可以向同一个提供方请求不同的边界,而无需修改提供方状态。 +策略在每次调用时完全解析并随调用携带。这使得并发消费方和一次性提权重试能够向同一个提供方请求不同的边界,而无需修改提供方状态。 ```ts type-equiv interface SandboxPolicy { @@ -41,7 +41,7 @@ interface SandboxPolicy { ## 包装后的 argv 与分类方言 -`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制事实和两组正交的 stderr 方言。`denialSignatures` 标识沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureSignatures` 标识沙箱运行器在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障暴露,而非普通任务失败。 +`ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 方言。`denialSignatures` 用于识别沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureSignatures` 用于识别沙箱运行器在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 ```ts type-equiv interface ConfinedArgv { @@ -75,10 +75,10 @@ interface ConfinedArgv { } ``` -运维人员配置的本地运行器必须为自身的 pre-exec 拒绝方言提供至少一条 `runnerFailureSignatures` 条目;提供方会自动添加外层 shell 的 missing 和 unexecutable 形式。这使得可执行的自定义运行器拒绝其 profile 的情况与被包装命令以相同状态码退出的情况可以区分开来。 +运维人员配置的本地运行器必须为自身的 pre-exec 拒绝方言提供至少一条 `runnerFailureSignatures` 条目;提供方会自动添加外层 shell 的 missing 和 unexecutable 形式。这使得可执行的自定义运行器拒绝其 profile 的情况能够与被包装命令以相同状态码退出的情况区分开来。 ## 提供方与 fail-closed 错误 `ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。已选定的运行器也可能在执行时 fail-closed,此时其失败签名承载相同的基础设施含义。对于受限策略,静默的无隔离透传永远不合法。 -提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 +提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定。 diff --git a/docs/core-data-structures/scope.i18n.yaml b/docs/core-data-structures/scope.i18n.yaml index 45f21bc10d..a9467c71da 100644 --- a/docs/core-data-structures/scope.i18n.yaml +++ b/docs/core-data-structures/scope.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write scope.md: f95594329ee9ac83da2efcc31df377c53e64331a -scope.zh.md: 80b2a7355e0499fcccf0e218c57f395252416cbd +scope.zh.md: 277fa4ec5c365e5ff3ee6d9baeae525d3dfc9f96 diff --git a/docs/core-data-structures/scope.zh.md b/docs/core-data-structures/scope.zh.md index 80b2a7355e..277fa4ec5c 100644 --- a/docs/core-data-structures/scope.zh.md +++ b/docs/core-data-structures/scope.zh.md @@ -2,19 +2,19 @@ [English](scope.md) | 中文 -[scope 包](../../packages/core/scope)提供身份标识与载体词汇,使一个注册上下文同时表达「按 agent 可见」和「共享生命周期所有权」两层含义。它是一个库级原语,而非 Cordis 服务;[agent-scope 运行时设计 RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) 拥有实现动机,包的 [README](../../packages/core/scope/README.md) 拥有可调用 API 与过滤语义。 +[scope 包(package)](../../packages/core/scope)提供身份标识与载体词汇,使一个注册上下文同时表达逐 agent(智能体)的可见性与共享的生命周期归属。它是一个库级原语,而非 Cordis 服务;[agent-scope 运行时设计 RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) 阐述了实现原理,包的 [README](../../packages/core/scope/README.md) 说明了可调用 API 与过滤语义。 源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts)。 ## 身份标识与分发载体 -`ScopeKey` 是一个不透明的对象标识。已交付的 agent loop 使用活跃的 `Agent` 对象作为自身的 key,但该原语从不检视该对象。 +`ScopeKey` 是一个不透明的对象身份标识。已交付的 agent loop(智能体循环)使用活跃的 `Agent` 对象作为自身的 key,但该原语从不检视该对象。 ```ts type-equiv type ScopeKey = object ``` -`Scoped<T>` 是 `scopeTarget(base, key)` 返回的不透明路由接收者上的编译期品牌类型。经作用域过滤的事件声明要求以此载体作为其 `this` 类型,而真正的事件主体仍作为显式参数传递。 +`Scoped<T>` 是编译期品牌标记,标注在 `scopeTarget(base, key)` 返回的不透明路由接收器上。作用域过滤的事件声明要求以此载体作为 `this` 类型,而真正的事件主体仍作为显式参数传入。 ```ts type-equiv type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } @@ -22,7 +22,7 @@ type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } ## 拥有所有权的注册上下文 -`Scope` 将带标签的注册上下文与两个拆卸面配对。`rawDispose` 保留有序组合副作用所需的精确 Cordis disposer 标识;`dispose()` 是面向直接调用方和竞争调用方的公共共享静默边界。 +`Scope` 将带标签的注册上下文与两个拆卸接口配对。`rawDispose` 保留有序复合 effect 所需的精确 Cordis disposer 身份;`dispose()` 是面向直接调用方和竞争调用方的公共静默边界,用于 dispose(资源释放)。 ```ts type-equiv interface Scope { diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index 53170b4ef6..ff5bae0fba 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session-query.md: 444f2bb2256a43df7bd8521dfe234f771eec7181 -session-query.zh.md: 4d00e5fa310d82c6099ab4f5255f09f9e253c150 +session-query.zh.md: 70f9737a702f84074962d9d1c7ac49a7c119f8cf diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 4d00e5fa31..70f9737a70 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -2,13 +2,13 @@ [English](session-query.md) | 中文 -对实时优先的逻辑会话语料库进行精确读取。[包(package)契约](../../packages/session-query/session-query)定义了源优先级、动态可选持久化、克隆、surface 分类、有界窗口与类型化错误。全文搜索是一个独立提议的 SQLite 阶段。 +对实时优先的逻辑会话语料库进行精确读取。[包(package)契约](../../packages/session-query/session-query)定义了源优先级、动态可选持久化、克隆、surface 分类、有界窗口与类型化错误。全文搜索是另一个拟议的 SQLite 阶段。 源码:[`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) ## 逻辑记录 -`SessionRecord` 由跨语料库列表返回。它独立于克隆的实时优先 header 暴露源可用性。`SessionEventRecord` 是一个轻量的原始日志投影;分类使用与 model-history 推导相同的 `foldSurface()` 状态转换。 +`SessionRecord` 由跨语料库列表返回。它独立于克隆后的实时优先 header 暴露源可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与 model-history 推导相同的 `foldSurface()` 状态转换。 ```ts type-equiv export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' @@ -34,7 +34,7 @@ export interface SessionEventRecord { ## 有界事件读取 -请求指定一个原始 seq 以及可选的前后邻近数量。结果携带 `SessionHeader` 而非可用性标志,使已知的实时目标可以独立于持久化健康状态。 +请求指定一个原始 seq 及可选的邻近数量。结果携带 `SessionHeader` 而非可用性标志,使已知的实时目标可以独立于持久化健康状态。 ```ts type-equiv export interface SessionEventReadRequest { @@ -57,7 +57,7 @@ export interface SessionEventWindow { ## 错误 -封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端失败与源元数据矛盾。 +封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端故障与矛盾的源元数据。 ```ts type-equiv export type SessionQueryErrorCode = diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 9910652ad0..23b16e8d66 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session.md: 796abebbc31a54c7c341028cf0a09031ae59cd78 -session.zh.md: a2ff9319af1425792702502e12bd287d7f3ca805 +session.zh.md: b55ff3b7fe9c3a5f65dc46addf266dc1e5430116 diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index a2ff9319af..b55ff3b7fe 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -2,13 +2,13 @@ [English](session.md) | 中文 -[dsh-session](../../packages/core/session) 的内存事件溯源模型。`Session` 是一份由类型化 `SessionEvent` 组成的**仅追加日志**,是 agent(智能体)整个交互历史的唯一真源。LLM(大语言模型)消息历史从日志*派生*而来,从不单独存储;回放即从同一组事件重新派生。日志如何实现**持久化**(持久化 seam、后端、崩溃恢复)是兄弟文档 [persistence.md](persistence.md) 的关注点。 +[dsh-session](../../packages/core/session) 的内存事件溯源模型。`Session` 是一份由类型化 `SessionEvent` 组成的**仅追加日志**,是 agent(智能体)完整交互历史的唯一真源。LLM(大语言模型)消息历史从日志*派生*而来,从不单独存储;回放即从同一组事件重新派生。日志如何实现**持久化**(持久化 seam、后端、崩溃恢复)是兄弟文档 [persistence.md](persistence.md) 的关注点。 源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ## `SessionEventMap`:事件词汇 -仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[压缩(compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并的),包括其 payload、surface 标记和声明位置。 +仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[上下文压缩(context compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件,用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。 ```ts type-equiv interface SessionEventMap { @@ -90,7 +90,7 @@ interface SessionEventMap { ### `TodoItem`:一条待办项 -`todo/write` 事件全量快照的单元。刻意保持最小化:一行 `content` 加一个三态 `status`(无 id、无优先级、无 `activeForm`)。列表在每次写入时整体替换,因此条目不需要稳定标识;三态 status 恰好对应 ACP 的 `PlanEntryStatus`,UI 桥接层可以将 todo 列表 1:1 映射到 ACP `plan`(ACP 额外要求的 priority 由桥接层合成)。见 [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md)。 +`todo/write` 事件全量快照的单元。刻意保持精简:一行 `content` 加一个三态 `status`(无 id、无优先级、无 `activeForm`)。列表在每次写入时整体替换,因此条目不需要稳定标识;三态 status 恰好是 ACP(Agent Client Protocol)的 `PlanEntryStatus`,UI 桥接层可以将待办列表 1:1 映射到 ACP `plan`(再合成 ACP 额外要求的优先级)。见 [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md)。 ```ts type-equiv export interface TodoItem { @@ -101,7 +101,7 @@ export interface TodoItem { ### 请求头事件:`request/header` 与 `request/header-delta` -请求信封(`EpochHeader`:调用配置 + 渲染后的系统提示词 + 组装好的工具 schema + 会话前缀)是被记录到日志中的会话状态,因此每次对话请求都是日志的纯函数(可重建性 RFC)。`request/header` 快照(reason 为 `'initial' | 'resume' | 'fallback'`)在对话创建、进程边界和 delta 编码回退时锚定折叠点;`request/header-delta` 事件在运行中修正它。`foldRequestHeader(events)` 可重建任何请求构建时所用的 header;写入器在记录每个 delta 前都会做往返验证,因此格式良好的日志总能折叠。两者都不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(`EpochHeader`:调用配置 + 渲染后的系统提示词 + 组装好的工具 schema + 会话前缀)是被记录到日志中的会话状态,使得每次对话请求都是日志的纯函数(可重建性 RFC)。`request/header` 快照(reason 为 `'initial' | 'resume' | 'fallback'`)在对话诞生、进程边界和 delta 编码回退时锚定折叠点;`request/header-delta` 事件在运行中修正它。`foldRequestHeader(events)` 可重建任一请求构建时所用的 header;写入器在记录每个 delta 前都会做往返验证,因此格式正确的日志总能折叠。两者都不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv export interface EpochHeader { @@ -122,11 +122,11 @@ export interface EpochHeader { } ``` -规范形式:空的系统提示词、空的工具列表和空的会话前缀表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix` + 派生历史);每个 agent loop(智能体循环)实例组装一次,由该实例的快照锚定,因此循环实际上不会产生 prefix delta。delta 分支(数组整体替换,空数组编码回到缺失状态的转换)为编解码完备性而存在。其他 delta payload(`SystemDelta`:公共前缀/后缀行裁剪;`ToolsDelta`:按名称键控的增/删/改)与事件一起定义在 [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) 中。 +规范形式:空的系统提示词、空的工具列表和空的会话前缀均为 ABSENT 字段,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix` + 派生历史);每个 agent loop(智能体循环)实例组合一次,由该实例的快照锚定,因此实际上 loop 不会产生前缀 delta。delta 分支(整数组替换,空数组编码「回到无前缀」的转换)存在是为了编解码的完备性。其他 delta payload(`SystemDelta`:公共前缀/后缀行裁剪;`ToolsDelta`:按名称键控的增/删/改)与事件一起定义在 [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)。 ## `SessionEvent<T>`:一条日志条目 -基于 `type` 的正规可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 可以收窄 `event.data` 而无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 +基于 `type` 的真正可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 能直接收窄 `event.data`,无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 ```ts type-equiv type SessionEvent<T extends SessionEventType = SessionEventType> = { @@ -150,13 +150,13 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -`SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。 +`SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 语句禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。 ## Surface 类型 -五种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`)携带 surface 元数据,声明它们如何加入派生的 surface 链表。见[会话 surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md)。 +五种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`)携带 surface 元数据,声明它们如何加入派生的 surface 链表。见 [session surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md)。 -### `SurfaceEventType`:产生消息的事件类型子集 +### `SurfaceEventType`:事件类型中产生消息的子集 ```ts type-equiv export type SurfaceEventType = @@ -175,7 +175,7 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } ``` -`'append'` 是正常的尾部追加路径。`replace` 遮蔽从 `start` 到 `end`(含两端,两者必须是有效的 surface 节点 seq;`start === end` 替换单个节点)的 surface 节点,并在其位置插入新节点。 +`'append'` 是正常的尾部追加路径。`replace` 遮蔽从 `start` 到 `end`(含两端)的 surface 节点(两者都必须是有效的 surface 节点 seq),并在其位置插入新节点。 ### `SurfaceIntent`:`session.append()` 的参数 @@ -186,7 +186,7 @@ export interface SurfaceIntent { } ``` -`SurfaceEventType` 事件必须提供此参数:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。 +对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。 ### `SurfaceNode`:surface 链表中的一个节点 @@ -220,22 +220,22 @@ export interface SurfaceFoldResult { ## 派生历史:`deriveMessages()` 与 `deriveEventMessage()` -`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,其中的消息是共享的深度冻结对象,因此无法通过投影来修改已记录的历史)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: +`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: - `user/message` → 一条 user 消息。 -- `assistant/message` → 一条 assistant 消息。原始 `assistant/chunk` 事件是回放/UI 数据,在派生中被**跳过**(组装后的消息才是权威的)。**空内容**的 `assistant/message` 也被跳过:max-tokens 截断且无内容的步骤仍会记录 `assistant/message` 以承载其 `usage`,但无内容的 assistant 轮次不得进入提供方的 transcript(文本记录)。 +- `assistant/message` → 一条 assistant 消息。原始 `assistant/chunk` 事件是回放/UI 数据,在派生中被**跳过**(组装后的消息才是权威的)。**空内容**的 `assistant/message` 也被跳过:一个因 max-tokens 截断且无内容的步骤仍会记录 `assistant/message` 以承载其 `usage`,但无内容的 assistant 轮次不得进入提供方的 transcript(文本记录)。 - `tool/result` → 一条携带 `tool-result` 块的 user 消息。 -- `context/message`、`steering/message` → 按时间顺序插入的 user 角色消息,包裹在标签信封中(`<context source="…">…</context>`)。这是"系统提醒"模式;模型通过信封将它们与真实提示词区分开来。 +- `context/message`、`steering/message` → 以 user 角色、按时间顺序插入的消息,包裹在标记信封中(`<context source="…">…</context>`),即「系统提醒」模式;模型通过信封区分它们与真实提示词。 -其他一切(`turn/*`、`step/*`)是结构性的,不投影为消息。token 用量在 `assistant/message.usage` 上观察(即产生它的那个步骤);操作错误的步骤编号在 `turn/end.reason` 中(`kind: 'error'` 时)。 +其他一切(`turn/*`、`step/*`)是结构性事件,不投影为消息。token 用量通过 `assistant/message.usage` 观察(产生该用量的步骤);操作错误的步骤号在 `turn/end.reason` 中(`kind: 'error'` 时)。 ## 活跃会话 fork API `ctx.sessions.create(id, { seed, meta })` 是底层的回放/fork 原语。对于普通的活跃会话 fork,`SessionStore` 暴露一个策略 API: -- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取源事件直到(含)`boundary` seq(默认:当前最后一个事件),要求 boundary 事件为 `turn/end`,然后创建一个活跃的子会话,包含深克隆的种子事件和子元数据(`parentSession`、`seedLength` 以及继承的 `cwd`)。 +- `fork(source, boundary?, childSessionId?)` 接受一个活跃的 `Session` 对象或活跃的 `SessionId`,选取到 `boundary` seq(含)为止的源事件(默认为当前最后一个事件),要求 boundary 事件必须是 `turn/end`,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据(`parentSession`、`seedLength` 及继承的 `cwd`)。 -显式 `boundary` 允许调用方从之前完成的轮次 fork,即使源有更新的事件或一个未关闭的当前轮次。API 拒绝非 `turn/end` 的 boundary,而不是静默裁剪。更广泛的轮次封闭性检查保留在既有的 `dsh-invariants` 插件和持久化修复路径中,而非在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀裁剪逻辑,因为工具时委托通常在父轮次打开时启动;普通的会话分支应显式指定所请求的 boundary。 +显式 `boundary` 允许调用者从之前完成的轮次 fork,即使源会话有更新的事件或正在进行的轮次。API 拒绝非 `turn/end` 的 boundary,而不是静默截断。更广泛的轮次封闭性检查留在既有的 `dsh-invariants` 插件和持久化修复路径中,不在 `fork()` 中重复。`dsh-subagent-fork` 保留其已完成前缀截断逻辑,因为工具时委托通常在父轮次仍然打开时启动;普通的会话分支应显式指定请求的 boundary。 ## 轮次的触发原因:`TurnTriggerMap` @@ -293,20 +293,20 @@ interface TurnEndReasonMap { } ``` -`max-tokens` 对应同名的模型调用 `FinishReason`:轮次中任何一个步骤出现 `max-tokens`,整个轮次就以 `max-tokens` 结束而非 `completed`(截断事实优先于后续续写),消费方可以区分正常停止与被截断的情况。但这仅相对于 `completed` 而言:`disposed`/`aborted`/`error` 结果优先级更高。`rejected` 是一个零步骤轮次,其整个提示词批次被 `agent/prompt-submit` 钩子阻止(ACP 桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不由循环发出的 reason,由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 +`max-tokens` 对应同名的模型调用 `FinishReason`:轮次中任何一个步骤出现 `max-tokens`,整个轮次就以 `max-tokens` 结束而非 `completed`(截断事实优先于后续的继续),消费方据此区分正常停止与被截断的情况。但这仅相对于 `completed` 而言:`disposed`/`aborted`/`error` 结果优先级更高。`rejected` 是一个零步骤轮次,其整批提示词被 `agent/prompt-submit` 钩子阻止(ACP 桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不由 loop 发出的原因,由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 ## 轮次封闭不变式 -每个会话事件都位于一个轮次**内部**(在 `turn/start` 与其对应的 `turn/end` 之间)。循环在 `turn/start` *之后*追加排队的 `user/message` 事件;空闲时的 `agent.inject()` 将其 `context/message` 包裹在一个一次性的 `injection` 轮次中。这使得轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为中断崩溃的尾部,而不会误丢合法记录的轮次间上下文。`dsh-invariants` 插件在开发环境中强制执行此不变式(在未打开的轮次中追加消息事件会抛出异常)。见[轮次封闭不变式 RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 +每个会话事件都存在于一个轮次**内部**(位于 `turn/start` 与其对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加排队的 `user/message` 事件;空闲时的 `agent.inject()` 将其 `context/message` 包裹在一个一次性的 `injection` 轮次中。这使得轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为中断崩溃的尾部,而不会误丢合法记录的轮次间上下文。`dsh-invariants` 插件在开发环境中强制执行此不变式(在无打开轮次时追加消息事件会抛出异常)。见[轮次封闭不变式 RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 ## 插件贡献的仅日志事件 -插件可以通过 declaration merging 向 `SessionEventMap` 添加额外类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个已打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 和溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 +插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 -钩子桥接的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在循环的已打开轮次内触发,因此其 `hook/*` 记录天然满足轮次封闭。`SessionStart` 没有 `hook/*` 记录(其注入的 `context/message` 就是持久证据),因为它没有可以容纳记录的已打开轮次(见[钩子桥接 RFC](../rfc/implemented/feature/2026-06-30-hook-bridges.md))。 +钩子桥接的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 打开的轮次内触发,因此其 `hook/*` 记录天然满足轮次封闭。`SessionStart` 不产生 `hook/*` 记录(其注入的 `context/message` 就是持久证据),因为它没有打开的轮次来容纳记录(见[钩子桥接 RFC](../rfc/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 -持久化后端所依赖的契约:持久日志逐字保存每个事件,**包括** `assistant/chunk`。`seq` 必须保持连续,因此不能从规范日志中过滤掉 chunk。所有 `event.data` 必须是 JSON 可序列化的;`Session.append` 在源头强制执行此约束(对不可序列化的数据抛出异常),因此坏事件永远不会进入日志,`session.events` 始终等于后端可以持久化的内容。添加一个携带不可序列化数据的事件类型,或破坏不变式插件所检查的轮次/步骤嵌套,都是对磁盘格式的破坏性变更。 +持久化后端所依赖的约定:持久日志逐字保存每个事件,**包括** `assistant/chunk`。`seq` 必须保持连续,因此不能从规范日志中过滤掉 chunk。所有 `event.data` 必须可 JSON 序列化;`Session.append` 在源头强制执行此约束(对不可序列化的数据抛出异常),因此坏事件永远不会进入日志,`session.events` 始终等于后端能持久化的内容。添加一个携带不可序列化数据的事件类型,或破坏不变式插件所检查的 turn/step 嵌套结构,都是对磁盘格式的破坏性变更。 消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 8958dbe359..32c97d6b0a 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write skills.md: b0a847cec05651b63e63de96423170a0fd7ca2a9 -skills.zh.md: db196d2058cd1ede2ef7c9fda7668720ea2824b9 +skills.zh.md: 201bf53e95b5cbf5f8873217acca3c478cf30860 diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index db196d2058..201bf53e95 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,13 +2,13 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。Skill 是可选指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill 能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。skill(技能)是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 ## 提供方注册表 -`ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化和发现属于 await 的 `list()`。提供方对象、选项和候选项以只读方式借用,语义字段会被校验。 +`ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败。 @@ -22,7 +22,7 @@ interface SkillProvider { ## 本地发现优先级 -内置的本地提供方按 rank 顺序扫描根目录: +内置的本地提供方按 rank 顺序扫描各根目录: | Rank | Source | Root | |---|---|---| @@ -32,11 +32,11 @@ interface SkillProvider { | 400 | `user-dsh` | `<dshHome>/skills` | | 500 | `user-agents` | `<agentsHome>/skills` | -项目根目录是最近的包含 `.git` 的祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 遍历通过文件系统服务探测 `.git`,使远程或沙箱化的工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 +项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 -## Skill 标识 +## Skill 身份 -Skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`<name>/SKILL.md`)和扁平 Markdown 文件(`<name>.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 +skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`<name>/SKILL.md`)和扁平 Markdown 文件(`<name>.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 ```ts type-equiv type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) @@ -44,7 +44,7 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' ## 摘要、候选项与完整定义 -`SkillSummary` 是注册表面向模型可调用的摘要形状。消费方自行选择渲染哪些字段;会话目录仅使用 `name` 和 `description`,从不使用正文或绝对文件路径。`disableModelInvocation` 将 skill 从模型列表中隐藏,但允许受信代码按名称加载。 +`SkillSummary` 是注册表中可供模型调用的摘要形状。消费方自行选择渲染哪些字段;会话目录仅使用 `name` 和 `description`,从不使用 body 或绝对文件路径。`disableModelInvocation` 将 skill 从模型列表中隐藏,但允许受信代码按名称加载。 ```ts type-equiv interface SkillSummary { @@ -58,7 +58,7 @@ interface SkillSummary { } ``` -`SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时回传。 +`SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。 ```ts type-equiv interface SkillCandidate extends SkillSummary { @@ -69,7 +69,7 @@ interface SkillCandidate extends SkillSummary { } ``` -`SkillDefinition` 是 `ctx.skills.get()` 返回的完整解析结果,供 `skill` 工具使用。`resourceBase` 告诉工具如何为本地、URL 或提供方管理的 skill 渲染相对资源指引。 +`SkillDefinition` 是 `ctx.skills.get()` 返回的完整解析结果,供 `skill` 工具使用。`resourceBase` 告知工具如何为本地、URL 或提供方管理的 skill 渲染相对资源引导。 ```ts type-equiv type SkillResourceBase = @@ -96,7 +96,7 @@ type SkillRegistration = Omit<SkillDefinition, 'provider'> & { ## 查找与配置 -Skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方工作。提供方接收同一个只读选项对象,用于缓存标识和加载。取消在目录选择前后(包括缓存命中)都会检查,并同时竞争发现和完整定义加载。如果找不到 git 根目录,本地提供方将提供的 cwd 本身视为项目根目录。 +skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 ```ts type-equiv interface SkillLookupOptions { @@ -105,7 +105,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 和 `customSkillDirs`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`)。消费方拥有其目录描述上限。 ```ts type-equiv interface Config { @@ -115,6 +115,6 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一个 user-role 的 `<system-reminder>`。目录包含按名称排序的 skill `name` 和经过规范化、XML 转义的 `description`;不包含正文、路径、来源、提供方和路由提示。前缀发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方配置的描述上限,默认 `500`,整数最小值 `3`。其仅限请求、记录于 header 的生命周期由 [session-prefix RFC](../rfc/implemented/feature/2026-07-07-session-prefix.md) 定义。 +`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一条 user-role 的 `<system-reminder>`。目录包含排序后的 skill `name` 和经过规范化、XML 转义的 `description`;不包含 body、路径、来源、提供方和路由提示。前缀发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方配置的描述上限,默认值 `500`,整数最小值 `3`。其仅请求级别、记录于 header 的生命周期由 [session-prefix RFC](../rfc/implemented/feature/2026-07-07-session-prefix.md) 定义。 -面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用 agent 的 cwd 加载完整定义,将未解决的 skill 报告为未知或不再可用,拒绝 `disableModelInvocation` 的 skill,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index b859346be3..6d4a5fdaf2 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write subagent.md: eb9160abaee26969aecdc533fb9fd56fae18b7fa -subagent.zh.md: f29ebcd50b5d3a6d961583e381b81eb88ab95823 +subagent.zh.md: c8f680217a9f62f245a4de81cbc546deba87bbed diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index f29ebcd50b..c8f680217a 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -2,15 +2,15 @@ [English](subagent.md) | 中文 -subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 类似,它是**一项可选能力**,不属于 agent loop(智能体循环)的主干,因此其词汇定义在这里而非 [core.md](core.md)。但它在一个维度上与其他所有 seam 不同:**多个提供方实现在同一个上下文中共存**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM 适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 +subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM 适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现是兄弟包(`dsh-subagent-spawn`、`-fork`、`-acp`);面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计动机见 [subagent RFC](../rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为兄弟包(`dsh-subagent-spawn`、`-fork`、`-acp`);面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计动机见 [subagent RFC](../rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) ## 两类能力,两种发现方式 -提供方通过一个静态描述符公布其**启动时**特性,服务在运行实例存在之前就会检查它;如果请求需要提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会接受后静默忽略。**运行时**特性(steering(中途引导)、resume)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法:方法的存在本身即为能力,TypeScript 的类型收窄就是发现机制。 +提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性(steering(中途引导)、resume)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法——方法的存在即为能力,TypeScript 的类型收窄即为发现机制。 ```ts type-equiv interface SubagentCapabilities { @@ -23,7 +23,7 @@ interface SubagentCapabilities { ## 启动请求 -工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前对照指定提供方进行校验。必填的 `parent` 提供会话 cwd、血统链和委派深度。可选的 output schema、depth、tool filter 和 persona 需要对应的能力标志位。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 限定在子 agent 创建阶段,并通过一个强制捕获工具实现所支持的 object-rooted schema。 +工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、tool filter 和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture tool 实现所支持的 object-rooted schema。 ```ts type-equiv interface SubagentStartRequest { @@ -38,11 +38,11 @@ interface SubagentStartRequest { } ``` -`signal` 是就绪前后唯一的取消通道。[subagent 组合控制 RFC](../rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 persona、实时全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 +`signal` 是就绪前后唯一的取消通道。[subagent 组合控制 RFC](../rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责 persona、运行时全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 ## 终态结果:`SubagentResult` -一次运行的结果,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到,提供方在子 agent 失败或结束时未产出有效捕获时可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整:消费方将其映射为 `isError` 的工具结果,而非把不完整的输出当作成功上报。 +一次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 ```ts type-equiv interface SubagentResult { @@ -52,7 +52,7 @@ interface SubagentResult { } ``` -`SubagentStopReason` 是一个[可合并扩展的派生联合类型](core.md#the-map--derived-union-pattern):后端可以添加变体,因此消费方应对已知 case 分支处理,并将未知的终态原因视为失败: +`SubagentStopReason` 是一个[可合并扩展的派生联合类型](core.md#the-map--derived-union-pattern)——后端可以添加变体,因此消费方应对已知 case 分支处理,将未知的终态原因视为失败: ```ts type-equiv interface SubagentStopReasonMap { @@ -64,9 +64,9 @@ interface SubagentStopReasonMap { } ``` -## 活跃运行:`SubagentRun` +## 活跃 run:`SubagentRun` -`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose 该运行以达到静止态。子 agent 失败以非 completed 的 stop reason resolve;只有无法表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过其存在性公布运行时能力。 +`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose(资源释放)该 run 以达到静止状态。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过自身的存在来公布运行时能力。 ```ts type-equiv interface SubagentRun { @@ -80,7 +80,7 @@ interface SubagentRun { ## 提供方 seam:`SubagentProvider` -每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子行为(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型的措辞,而不暗示继承了工具、服务或权限。 +每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型的措辞,而不暗示继承了工具、服务或权限。 ```ts type-equiv interface SubagentProvider { @@ -91,11 +91,11 @@ interface SubagentProvider { } ``` -`start()` 仅在运行就绪时才 fulfill。服务观察其 result、发出 `subagent/start`,并返回同一个 run;rejection 意味着提供方已自行清理,且不发出生命周期事件对。进程内子 agent 可通过 `ctx.agents` 发现,远程子 agent 则不必如此。`subagent/end` 报告最终输出或基础设施故障。两个事件均为仅观察事件,包含监听器异常。 +`start()` 仅在 run 就绪时 fulfill。服务观察其 result、发出 `subagent/start`,并返回同一个 run;rejection 意味着提供方已自行清理,不发出生命周期配对事件。进程内子 agent 可通过 `ctx.agents` 发现,远程子 agent 则不必如此。`subagent/end` 报告最终输出或基础设施故障。两个事件均为仅观察事件,包含监听器异常。 ## 进程内后端:深度与种子 -spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建过程,并通过 `AgentHandle` 进行 dispose。提供方被移除时会阻止新的 start,但不会撤销已接受的运行。每个子 agent 获得一个新的扁平作用域,而非继承父级的注册。深度和 fork 种子复用既有的 agent 与会话词汇: +spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇: -- **委派深度**是一个可合并扩展的 `AgentOptions.subagentDepth` 字段(顶层 agent 为 `0`,子 agent 为 parent + 1)。只有 `undefined` 表示顶层;每个已存储的 present 值必须是非负安全整数。该 seam 拥有此字段:循环既不设置也不读取它。嵌套 spawn 校验其父级的已存储深度,拒绝超出安全整数范围的派生子深度,并将已定义的绝对 `request.maxDepth` 上限应用于该子 agent。 -- **Fork 种子**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 resume 使用的是同一原语)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*:父级事件直到并包含其最后一个 `turn/end`。因此种子从 0 开始连续,[invariants](../../packages/support/invariants) 的回放能接受它(进行中的、未平衡的轮次被排除在外)。 +- **委派深度**是一个可合并扩展的 `AgentOptions.subagentDepth` 字段(顶层 agent 为 `0`,子 agent 为 parent + 1)。只有 `undefined` 表示顶层;所有已存储的值必须是非负安全整数。该字段归 seam 所有——循环既不设置也不读取它——因此嵌套 spawn 会校验父级的已存储深度,拒绝超出安全整数域的派生子深度,并在定义了绝对 `request.maxDepth` 上限时将其施加于子 agent。 +- **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `resume` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml index b07308b84a..f2918af35f 100644 --- a/docs/core-data-structures/system-prompt.i18n.yaml +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write system-prompt.md: 175f2af407e5c39a24f0f8f8e4e664063b897ead -system-prompt.zh.md: ea1f48c56f18c97203e1052d6d0eca72710e942d +system-prompt.zh.md: 8827ac0b915915f716643e0a9e04edaacedbc868 diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md index ea1f48c56f..8827ac0b91 100644 --- a/docs/core-data-structures/system-prompt.zh.md +++ b/docs/core-data-structures/system-prompt.zh.md @@ -2,13 +2,13 @@ [English](system-prompt.md) | 中文 -[system-prompt 包](../../packages/core/system-prompt)定义了提示词贡献方与单次组装调用之间交换的数据。包的 [README](../../packages/core/system-prompt/README.md) 文档记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 +[system-prompt 包](../../packages/core/system-prompt)负责管理 prompt 贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 ## 组装上下文 -`AssembleContext` 标识单次组装所解析的作用域层。它可通过合并扩展:`dsh-agent` 添加了可选的运行时 `agent` 字段,`assembleContextFor(agent)` 同时设置该字段与 `scope`。 +`AssembleContext` 标识一次组装所解析的作用域层。它可通过合并扩展:`dsh-agent` 添加可选的活跃 `agent` 字段,`assembleContextFor(agent)` 同时设置该字段与 `scope`。 ```ts type-equiv interface AssembleContext { @@ -18,7 +18,7 @@ interface AssembleContext { ## 工具提供方结果 -`ToolProviderResult.schemas` 是当前组装中模型可见的工具集。`knownNames` 是提供方在限制前的完整名称集合,用于区分「配置名拼写错误」与「已知工具在此作用域下被有意隐藏」。 +`ToolProviderResult.schemas` 是当前组装中对模型可见的工具集合。`knownNames` 是提供方在限制前的名称全集,用于区分「配置名拼写错误」与「已知工具在此作用域中被有意隐藏」。 ```ts type-equiv interface ToolProviderResult { @@ -27,9 +27,9 @@ interface ToolProviderResult { } ``` -## 提示词段 +## Prompt 段落 -`PromptSection` 是一个只读的同进程注册契约。其文本可以是静态的,也可以从当前组装上下文动态解析。 +`PromptSection` 是一份只读的同进程注册契约。其文本可以是静态的,也可以从当前组装上下文动态解析。 ```ts type-equiv interface PromptSection { diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 5b2b306ec9..1b2db95b67 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write tools.md: f8be67054cd81027d4b751329948a784fa4f0ed9 -tools.zh.md: 080d0d99decb8630525e434a8079e29591e63ce9 +tools.zh.md: 8b77259b37b0e4c2118cc91af0e6dc3ca27c0479 diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 080d0d99de..8b77259b37 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -2,13 +2,13 @@ [English](tools.md) | 中文 -[dsh-tools](../../packages/core/tools) 的工具流水线。[core.md](core.md) 介绍了 `ToolDefinition` 作为唯一被提升到主干的流水线编写类型,以及 `ToolSchema` 作为面向模型的协议格式(wire format)。本页拥有完整的 `ToolDefinition`、构建它的类型化 schema DSL、带守卫的执行形状,以及 UI 展示词汇。 +[dsh-tools](../../packages/core/tools) 的工具流水线。[core.md](core.md) 介绍了 `ToolDefinition`(唯一被提升到主干的流水线编写类型)和 `ToolSchema`(面向模型的协议格式(wire format)形状)。本页拥有完整的 `ToolDefinition`、用于构建它的类型化 schema DSL、受保护的执行形状,以及 UI 展示词汇。 源码:[`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) -## `ToolDefinition`:一个已注册的工具 +## `ToolDefinition` — 一个已注册的工具 -一个 `ToolSchema`(面向模型的字段)加上 `execute` 函数与可选的 UI 展示器。注册表持有这些定义;agent loop(智能体循环)通过它们分发调用。注册表的 `schemas()` 通过显式白名单构建面向模型的 `ToolSchema[]`:`execute`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。 +一个 `ToolSchema`(面向模型的字段)加上 `execute` 函数和可选的 UI 展示器。注册表持有这些定义;agent loop(智能体循环)通过它们分派调用。注册表的 `schemas()` 通过显式白名单构建面向模型的 `ToolSchema[]`——`execute`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。 ```ts type-equiv interface ToolDefinition extends ToolSchema { @@ -42,11 +42,11 @@ interface ToolDefinition extends ToolSchema { } ``` -`execute` 接收 `args: unknown`:原始的 `ToolDefinition` 自行校验输入。第一方工具不需要手写校验;它们使用 `defineTool`,由后者代为校验和收窄类型。 +`execute` 接收 `args: unknown`——原始的 `ToolDefinition` 自行校验输入。第一方工具不需要手写校验;它们使用 `defineTool`,由后者代为校验并收窄类型。 ## 类型化 schema DSL -插件作者为每个属性编写带有布尔值 `required: true` 的规格,类型层面的辅助工具将规格映射为 `execute` 的参数类型——零类型断言。该 DSL 是为 `ToolDefinition` *提供类型*的机制;它有意作为子页面细节,不属于核心。 +插件作者为每个属性编写带有布尔值 `required: true` 的规格,类型层面的辅助工具将规格映射为 `execute` 的参数类型——零类型断言。该 DSL 是为 `ToolDefinition` 提供类型的*机制*;它有意作为子页面细节,而非核心内容。 源码:[`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) @@ -72,7 +72,7 @@ interface SchemaProp { type SchemaSpec = Record<string, SchemaProp> ``` -`SchemaType` 是原始联合类型 `'string' | 'number' | 'boolean' | 'object' | 'array'`。`InferArgs<S>` 将一个 `SchemaSpec` 映射为 TS 参数类型:`required: true` 的属性成为必选键,其余为真正的可选: +`SchemaType` 是原始联合类型 `'string' | 'number' | 'boolean' | 'object' | 'array'`。`InferArgs<S>` 将一个 `SchemaSpec` 映射为 TS 参数类型——`required: true` 的属性成为必选键,其余为真正的可选: ```ts type-equiv type InferArgs<S extends SchemaSpec> = Simplify< @@ -81,11 +81,11 @@ type InferArgs<S extends SchemaSpec> = Simplify< > ``` -`defineTool({ name, description, parameters, execute, … })` 将各部分串联:`parameters` 是一个 `SchemaSpec`,`execute(args, exec)` 得到 `args: InferArgs<typeof parameters>`,辅助函数将规格转换为 JSON Schema(`schemaSpecToJsonSchema`)用于协议传输,并在类型化函数体运行前校验模型生成的参数(`validateArgs`)。不匹配时抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`),注册表将其转为 `isError` 结果以便模型自我修正。为什么用自定义 DSL 而非 schemastery:工具参数需要的是 JSON Schema(LLM(大语言模型)协议格式),不是校验/转换——轻量 DSL 以最小表面积提供最佳编写体验。 +`defineTool({ name, description, parameters, execute, … })` 将各部分串联:`parameters` 是一个 `SchemaSpec`,`execute(args, exec)` 获得 `args: InferArgs<typeof parameters>`,辅助函数将规格转换为 JSON Schema(`schemaSpecToJsonSchema`)用于协议传输,并在类型化函数体运行前校验模型生成的参数(`validateArgs`)。校验不通过时抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`),注册表将其转为 `isError` 结果以便模型自行修正。为何用自定义 DSL 而非 schemastery:工具参数需要 JSON Schema(LLM(大语言模型)的协议格式),而非校验/转换——轻量 DSL 以最小的接口面积提供最佳的编写体验。 -注册是受信的同进程契约。注册表以 readonly 方式借用类型化定义作为输入,仅校验语义要求(如 `timeoutMs` 必须为正有限值);`schemas()` 在模型边界处具象化显式的面向模型投影,使执行与展示共享同一份已解析定义,而不会将回调泄漏到协议上。 +注册是一个受信任的同进程契约。注册表以 readonly 输入借用类型化定义,仅校验语义要求(如 `timeoutMs` 必须为正有限值);`schemas()` 在模型边界处物化显式的面向模型投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 -## `ToolRestriction`:单个作用域的实时全局过滤器 +## `ToolRestriction` — 单个作用域的实时全局过滤器 `ToolRestriction` 仅作用于实时的部署全局工具层。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域本地工具。仅 deny 的过滤器允许后续未列出的全局工具通过,而 allow 列表则排除它们。 @@ -98,7 +98,7 @@ interface ToolRestriction { ## 执行:可扩展的 waterfall(瀑布式事件)加单调策略 -`ctx.tools.execute()` 接受调用方拥有的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性具象化为流水线拥有的 `ToolExecution`,然后将该调用依次通过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调守卫 → `tools/execute`(around-dispatch 包装层)→ `tools/post-execute`(检查/替换结果)→ `tools/result`(不可变的权威结果)。最终结果是一个 `ToolExecutionResult`。 +`ctx.tools.execute()` 接收调用方拥有的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性物化为流水线拥有的 `ToolExecution`,然后依次通过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard → `tools/execute`(around-dispatch 包装层)→ `tools/post-execute`(检查/替换结果)→ `tools/result`(不可变的权威结果)。最终产出为 `ToolExecutionResult`。 ```ts type-equiv type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } @@ -129,9 +129,9 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` 是一个不透明的运行时 `Symbol`,仅用于身份比较。在策略执行之前,`execute()` 具象化并冻结参数、拒绝非 JSON 输入、分配 token。身份字段和可选的 parent token 保持 readonly;只有 `signal` 可在 dispatch 前后变化。最终观察者接收到的是冻结的执行身份。 +`ToolExecutionToken` 是一个不透明的运行时 `Symbol`,仅用于身份比较。在策略执行之前,`execute()` 物化并冻结参数、拒绝非 JSON 输入、分配 token。身份字段和可选的 parent token 保持 readonly;只有 `signal` 可以在分派前后变化。最终观察者接收到的是冻结的执行身份。 -`ToolGuard` 是感知作用域的最终 pre-dispatch 策略。其形状有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 +`ToolGuard` 是感知作用域的最终预分派策略。其形状有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 ```ts type-equiv type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined @@ -168,9 +168,9 @@ interface ToolExecutionResult { } ``` -结果仅承载结果本身。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果通过每个钩子,也保留在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。 +结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。 -注册表在 `tools/result` 之前立即具象化并冻结最终接受的结果。其 content、结构化错误、附加上下文和展示元数据必须通过 JSON 无损往返;无效结果会被转为 JSON 安全的 `isError` 结果,确保被观察到的实时结果对后续持久化的 `tool/result` 追加是安全的。 +注册表在 `tools/result` 之前立即物化并冻结最终接受的结果。其内容、结构化错误、附加上下文和展示元数据必须通过 JSON 无损往返;无效的产出会被转为 JSON 安全的 `isError` 结果,从而保证被观察到的实时产出对后续持久化的 `tool/result` 追加是安全的。 每个拦截 waterfall 返回一个类型化的 **Decision**(与 `agent/*` seam 共享的惯用模式)。`tools/pre-execute` 监听器接收 `(exec, next)` 并返回 `PreToolDecision`;`tools/execute` 包装层返回 `ToolExecutionResult`;`tools/post-execute` 监听器接收 `(exec, result, next)` 并返回 `PostToolDecision`: @@ -187,13 +187,13 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -调用 `next()` 走默认路径,或返回 decision 以短路。Pre-policy 可以 deny 或 ask;只有 `allowed-once` 才继续执行,而 non-grant、缺少审批通道或服务、或无 agent 的请求都会变为 denial。守卫仍可施加最终 denial。参数不可被改写,因为历史记录、审计、UI 和执行必须一致。 +调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 -Post-policy 可以替换 content;block 会变为包含其纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法转换它们,观察者的失败被隔离。未知工具和抛出异常的工具都变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 +后置策略可以替换内容;block 会变为包含纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 ## 结构化输出 schema 子集 -调用方用来向 subagent 要求机器可读结果的词汇(`SubagentStartRequest.outputSchema`,见 [subagent.md](subagent.md#the-start-request)),或工作流 `agent()` 调用使用的词汇。它有意**不是**完整的 JSON Schema:schema 原样传递给模型作为强制工具的 `parameters`,产出的值由客户端的 `validateStructuredValue` 校验——因此每个被接受的关键字都必须是校验器实际执行的,`assertSupportedOutputSchema` 会大声拒绝其他任何内容(`OutputSchemaError`,列出所有违规)。两个遍历器都只处理自有可枚举属性(JSON 不携带其他东西),并拒绝会有损序列化的非普通对象(`Date`、`Map`)。 +调用方用来向 subagent 要求机器可读结果的词汇(`SubagentStartRequest.outputSchema`,见 [subagent.md](subagent.md#the-start-request)),或工作流 `agent()` 调用使用的词汇。它有意**不是**完整的 JSON Schema:schema 原样传给模型作为强制工具的 `parameters`,产出的值由 `validateStructuredValue` 在客户端校验——因此每个被接受的关键字都必须是校验器实际执行的,`assertSupportedOutputSchema` 会大声拒绝其他任何内容(`OutputSchemaError`,列出所有违规项)。两个遍历器仅推理自有可枚举属性(JSON 不携带其他内容),并拒绝会有损序列化的非纯对象(`Date`、`Map`)。 ```ts type-equiv type StructuredScalar = string | number | boolean | null @@ -219,7 +219,7 @@ interface StructuredSchemaNode { } ``` -schema 是一个以 object 为根的节点(`enum`/`const` 仅限标量;`description`/`title`/`default`/`examples` 是注解,允许但忽略,仍要求为 JSON 数据——它们随协议传输): +schema 是一个以 object 为根的节点(`enum`/`const` 仅限标量;`description`/`title`/`default`/`examples` 是注解,允许但忽略,但仍要求为 JSON 数据——它们随协议传输): ```ts type-equiv type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } @@ -227,11 +227,11 @@ type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } ## 工具展示 UI 词汇 -工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI 日志行),提供方无关,使工具无需依赖任何客户端协议即可描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: +工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI 日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: -- `ToolCallView`(pending 状态):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示该调用读取/修改的文件,供编辑器跟随定位)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令 → 终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改 → 内联 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,`oldText: null` 表示新文件)。 -- `ToolResultView`(completed 状态):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,无能力的 UI 获得桥接层从 `output` 派生的围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更 → 要展示的变更,通常是从 before/after 内容计算出带上下文行的已应用 hunk,或在没有 before-image 时的整文件 diff——如文件创建。`tool_call_update` 的 content 会**替换**调用的 content,因此变更工具即使与调用时的片段重复也要返回此值,以防结果文本覆盖 diff)。 +- `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 +- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,无能力的 UI 获得桥接层从 `output` 派生的围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff——例如文件创建。`tool_call_update` 的内容会**替换**调用的内容,因此变更工具即使与调用时的片段重复也要返回此卡片,以防结果文本覆盖 diff)。 -`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)为 generic 卡片选择图标。`FileLocation`(`{ path, line? }`)和 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计固定于[渲染意图联合类型 RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md);ACP(Agent Client Protocol)桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并将文件卡片的标题相对于会话 cwd 做相对化处理。 +`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)为 generic 卡片选择图标。`FileLocation`(`{ path, line? }`)和 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计固定在[渲染意图联合类型 RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) 中;ACP 桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并将文件卡片的标题相对于会话 cwd 做相对化处理。 完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。bash 工具自身的 schema(`bash`/`bash_output`/`bash_kill`)及其驱动的执行器见 [bash.md](bash.md)。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index f80530393b..d0c0b33ce5 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write user-interaction.md: 47e0e26cd0a5201185dd252a882496456a9c3edd -user-interaction.zh.md: bddcc991fd48d475f06912b9134b331d623b80d2 +user-interaction.zh.md: e7d2f27b8729a7694008d1ae7e21bdccae6058dd diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index bddcc991fd..e7d2f27b87 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -2,13 +2,13 @@ [English](user-interaction.md) | 中文 -[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是工具或权限插件在需要人类回答后 agent 才能继续时所使用的提供方无关词汇。UI 表面提供活跃的 `UserInteractionProvider`:`dsh-stdio-demo` 在 readline 中渲染问题,`dsh-acp` 将其映射为 ACP 表单引出。 +[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是提供方无关的词汇,工具或权限插件在需要人类回答后 agent(智能体)才能继续时使用这套词汇。UI 表面提供活跃的 `UserInteractionProvider`:`dsh-stdio-demo` 在 readline 中渲染问题,`dsh-acp` 将其映射为 ACP(Agent Client Protocol)表单征询。 源码:[`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) ## 问题选项 -`AskUserQuestionOption` 是可选择项的形状。`label` 是面向用户的选项文字,同时也是模型侧选中后的值;`description` 是可选的 UI 辅助文字。 +`AskUserQuestionOption` 是可选择项的形状。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 ```ts type-equiv interface AskUserQuestionOption { @@ -40,7 +40,7 @@ interface AskUserQuestionItem { ## 提问请求 -`AskUserQuestionRequest` 是跨包请求。`questions` 是数组,这样 UI 可以在一次流程中展示相关问题,同时为每个回答保留稳定的 id。 +`AskUserQuestionRequest` 是跨包(package)的请求。`questions` 是数组,这样 UI 可以在一个流程中呈现相关提示,同时保持每个回答有稳定的 id。 ```ts type-equiv interface AskUserQuestionRequest { @@ -55,7 +55,7 @@ interface AskUserQuestionRequest { ## 回答 -提供方为每个已回答的问题 id 返回一条回答。`selected` 包含选中的选项 label,`custom` 在用户输入了自由文本"其他"答案时携带该内容。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。 +提供方为每个已回答的问题 id 返回一条回答。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。 ```ts type-equiv interface AskUserQuestionAnswerItem { @@ -77,7 +77,7 @@ interface AskUserQuestionAnswer { ## 提供方 -同一上下文中只能有一个活跃的提供方。提供方注册与 effect 绑定,因此 HMR(热模块替换)或 dispose(资源释放)会移除活跃的 UI。 +同一上下文中只能有一个活跃的提供方。提供方注册绑定到 effect,因此 HMR(热模块替换)或 dispose(资源释放)会移除当前活跃的 UI。 ```ts type-equiv interface UserInteractionProvider { @@ -87,7 +87,7 @@ interface UserInteractionProvider { ## 错误 -`UserInteractionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会为面向模型的工具失败保留 `{ name, code }`,例如 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`ASK_ABORTED` 或 ACP 侧的取消。 +`UserInteractionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会保留 `{ name, code }`,用于面向模型的工具失败,如 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`ASK_ABORTED` 或 ACP 侧取消。 ```ts type-equiv class UserInteractionError extends HarnessError { diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml index b5b4fd1f29..7c248359a9 100644 --- a/docs/core-data-structures/web.i18n.yaml +++ b/docs/core-data-structures/web.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write web.md: 74db18835df02ef233f78ad7fbfec5d9b26d58e6 -web.zh.md: da8dad9dc06309b6f3108148dc39bc2b66bb5d22 +web.zh.md: 7b94aa457985075c79052aeef66b2e46eac5b49d diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md index da8dad9dc0..7b94aa4579 100644 --- a/docs/core-data-structures/web.zh.md +++ b/docs/core-data-structures/web.zh.md @@ -2,17 +2,17 @@ [English](web.md) | 中文 -Web 访问 seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md),在单一 `ctx.web` 服务上横跨**两种能力**(搜索与抓取),拆分到多个包(package)中:接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local)),以及消费方([dsh-tool-web](../../packages/web/tool-web),`web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此,而非 [core.md](core.md)。更换搜索提供方不会改变模型发起查询的方式,更换抓取实现也不会改变模型请求 URL 的方式。 +Web 访问 seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md),在一个 `ctx.web` 服务上横跨**两项能力**(搜索与抓取),分布在多个包(package)中:接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local)),以及消费方([dsh-tool-web](../../packages/web/tool-web),`web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。更换搜索提供方不会改变模型发起查询的方式,更换抓取实现也不会改变模型请求 URL 的方式。 源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) -## 为何两种能力共用一个 seam +## 为什么两项能力合为一个 seam -搜索与抓取既不共享请求 schema,也不共享业务逻辑,但它们被有意设计为同一个 `ctx.web` 中间层:一个提供方选择策略的归属者、一套 abort/error 词汇、一个面向产品的「此 harness 如何访问 Web」配置界面。代价是服务上出现了并行的 `searchX`/`fetchX` 方法对;这种并行是有意为之,而非遗漏的提取。提供方注册的是**能力**(`WebSearchProvider` 或 `WebFetchProvider`),而非工具;面向模型的名称、schema、prompt 引导与展示全部集中在唯一的消费方 `dsh-tool-web` 中。 +搜索与抓取既不共享请求 schema,也不共享业务逻辑,但它们被有意设计为同一个 `ctx.web` 中间层:一个提供方选择策略的所有者、一套 abort/error 词汇、一个面向产品的「此 harness 如何访问 Web」配置界面。代价是服务上并行的 `searchX`/`fetchX` 方法对;这种并行是有意为之,而非遗漏的提取。提供方注册的是**能力**(`WebSearchProvider` 或 `WebFetchProvider`),而非工具;面向模型的名称、schema、prompt 引导与展示全部集中在唯一的消费方 `dsh-tool-web` 中。 ## 搜索请求与结果 -面向模型的工具参数仅为一个 `query`;`maxResults` 是消费方持有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行:如果提供方返回的结果超量,seam 会截断 `sources[]` 并设置 `truncated`。 +面向模型的工具参数仅为一个 `query`;`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。 ```ts type-equiv interface WebSearchRequest { @@ -33,7 +33,7 @@ interface WebSearchResult { } ``` -`content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是可移植的引用界面。每条 source 必有 `url`;`title`/`snippet`/`publishedAt` 可选,因为并非所有提供方都返回它们:Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 +`content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是可移植的引用表面。一个 source 必有 `url`;`title`/`snippet`/`publishedAt` 可选,因为并非每个提供方都返回它们——Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 ```ts type-equiv interface WebSearchSource { @@ -52,7 +52,7 @@ interface WebFetchRequest { } ``` -HTTP 状态码是被抓取资源状态的一部分,不自动视为失败:成功的网络抓取返回 `404`/`500` 时,结果仍是一个带状态码和有界解码 body 的 `WebFetchResult`。`url` 是经过允许的重定向后的最终 URL。`WebError` 保留给无法安全获取或表示资源的失败情形。 +HTTP 状态码是被抓取资源状态的一部分,不自动视为失败:成功的网络抓取返回 `404`/`500` 时,仍产出一个带状态码和有界解码 body 的 `WebFetchResult`。`url` 是经过允许的重定向后的最终 URL。`WebError` 仅用于无法安全获取或表示资源的情况。 ```ts type-equiv interface WebFetchResult { @@ -63,7 +63,7 @@ interface WebFetchResult { } ``` -`WebFetchBody` 是 `dsh-web` 持有的**封闭**可辨识联合类型(不是可合并扩展的 map):提供方解码 kind,`dsh-tool-web` 渲染它,因此新增一个 kind 是跨已知包的协调变更,而非插件扩展。消费方对 `kind` 做 `switch` 并以 `default: assertNever(...)` 结尾,因此新增 kind 会在每个消费方处破坏编译直到被处理。即使当前各分支字段相同,每个分支仍保持独立的对象字面量,为将来的分支特有字段留出空间(例如未来 `pdf` body 的 `pageCount`)。 +`WebFetchBody` 是 `dsh-web` 拥有的**封闭**可辨识联合类型(不是可合并扩展的 map):提供方解码 kind,`dsh-tool-web` 渲染它,因此新增一个 kind 是已知包之间的协调变更,而非插件扩展。消费方对 `kind` 做 `switch` 并以 `default: assertNever(...)` 结尾,所以新增 kind 会在每个消费方处编译失败,直到被处理。即使各分支当前字段一致,每个分支仍保持独立的对象字面量,为将来分支特有字段留出空间(例如未来 `pdf` body 的 `pageCount`)。 ```ts type-equiv type WebFetchBody = @@ -73,14 +73,14 @@ type WebFetchBody = ## 提供方可用性 -提供方的 `available(): boolean` 是一个廉价的**本地**检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择的输入,而非健康检查系统:`search()`/`fetch()` 读取它来选出可用的提供方,选择失败以结构化的 `WebError` 呈现给调用方路由,其 code 和 message 携带可分支的细节(缺失的 id 或歧义的候选集)。 +提供方的 `available(): boolean` 是一个廉价的**本地**检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择的输入,而非健康检查系统:`search()`/`fetch()` 读取它以选出可用的提供方,选择失败以结构化的 `WebError` 呈现给调用方路由——其 code 和 message 携带可分支的细节(缺失的 id 或有歧义的候选集)。 -选择从不依赖注册顺序、配置顺序或 HMR 顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或喂入同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;多个可用提供方且未配置 id 时为 `WEB_PROVIDER_AMBIGUOUS`,而非先注册先赢。 +选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;多个可用提供方且未配置 id 时为 `WEB_PROVIDER_AMBIGUOUS`,而非先注册先赢。 ## 错误 -`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致:`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的 code,消费方必须容忍未知 code。code 按归属者划分。seam 中性的 code 由 `WebService` 选择逻辑和共享契约抛出:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身失败通过 seam 暴露的兜底 code,包括网络/传输失败:DNS、连接被拒、TLS)。抓取传输层 code 由 `dsh-web-fetch-local` 实现持有,不同的抓取后端不必抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 +`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的 code,消费方必须容忍未知 code。code 按所有者划分。seam 中立的 code 由 `WebService` 选择逻辑和共享契约抛出:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障通过 seam 暴露的兜底 code,包括网络/传输失败——DNS、连接被拒、TLS)。抓取传输层 code 由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 ## 服务 -`WebService` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、限制重定向次数、字节数、字符数与时间、对每一跳同源重定向重新校验,并解码 body;展示由工具负责。私有网络阻断尚未实现,因此不要在能触及敏感内部目标的环境中启用 `web_fetch`。 +`WebService` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、限制重定向次数、字节数、字符数和时间、对每一跳同源重定向重新校验,并解码 body;展示由工具负责。私有网络阻断尚未实现,因此请勿在可触及敏感内部目标的环境中启用 `web_fetch`。 diff --git a/docs/core-data-structures/workflow.i18n.yaml b/docs/core-data-structures/workflow.i18n.yaml index b7e831aac8..c06899bc74 100644 --- a/docs/core-data-structures/workflow.i18n.yaml +++ b/docs/core-data-structures/workflow.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write workflow.md: 1571723c172fe851e89550e4ed8588ddb14088a0 -workflow.zh.md: 9fa3b4eea4efdcdb8e594c3558e30846aea0af46 +workflow.zh.md: 68a5ff9b6ddf43145966e60706a42a758ea260ba diff --git a/docs/core-data-structures/workflow.zh.md b/docs/core-data-structures/workflow.zh.md index 9fa3b4eea4..68a5ff9b6d 100644 --- a/docs/core-data-structures/workflow.zh.md +++ b/docs/core-data-structures/workflow.zh.md @@ -2,15 +2,15 @@ [English](workflow.md) | 中文 -工作流 seam:由 agent(智能体)运行一段模型编写的编排脚本(SCRIPT),向外扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 +工作流 seam:一个 agent(智能体)运行由模型编写的编排脚本(SCRIPT),扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 -接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现为 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(基于 `node:worker_threads` 的引擎:每次运行一个 worker,脚本的 vm 上下文在其中执行);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见[动态工作流 RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md)。 +接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎:每次运行一个 worker,脚本的 vm 上下文在其中执行);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计动机见[动态工作流 RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md)。 源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) ## 启动请求 -调用方启动一次运行时发出的请求。工具层根据模型的 `{ script, meta, args }` 调用加上发起调用的 agent 构建此请求;`meta` 和 `args` 是纯 JSON 数据(引擎在任何代码运行之前对 `meta` 做形状校验,不通过则大声拒绝——永远不会为了获取 meta 而执行脚本文本)。`parent` 是必需的:脚本 spawn 的每个子 agent 都归属于它(cwd、血统和深度通过 [subagent seam](subagent.md) 流转)。 +调用方启动一次运行时提交的内容。工具层从模型的 `{ script, meta, args }` 调用加上发起调用的 agent 构建此请求;`meta` 和 `args` 是纯 JSON 数据(引擎在任何代码执行之前对 `meta` 做形状校验,不通过则立即报错:永远不会为了获取 meta 而执行脚本文本)。`parent` 是必填项:脚本 spawn 的每个子 agent 都归属于它(cwd、血统与深度通过 [subagent seam](subagent.md) 传递)。 ```ts type-equiv interface WorkflowStartRequest { @@ -24,7 +24,7 @@ interface WorkflowStartRequest { ## 工作流的身份标识:`WorkflowMeta` -作为数据附在启动请求上的身份块(工具的 `meta` 参数;字段词汇与 Claude Code 动态工作流的 meta 块一致)。`phases` 仅为进度词汇:`phase()` 调用与标题匹配供观察者使用;不暗示任何执行结构。 +作为数据附在启动请求上的身份块(工具的 `meta` 参数;字段词汇与 Claude Code 动态工作流的 meta 块一致)。`phases` 仅用于进度展示:`phase()` 调用与标题匹配,供观察者使用;不暗示任何执行结构。 ```ts type-equiv interface WorkflowMeta { @@ -37,7 +37,7 @@ interface WorkflowMeta { ## 终态结果:`WorkflowResult` -一次运行的结果,由 `WorkflowRun.result` resolve。`value` 是脚本的物化返回值——纯宿主域 JSON 数据(脚本无返回值时为 `null`)——仅在 `completed` 时有意义。`stopReason` 是一个封闭联合类型(引擎拥有;消费方可穷举):`completed` | `cancelled` | `error`。非 `completed` 的原因在 `error` 中携带失败信息,消费方将其映射为 `isError` 工具结果,而非把部分输出当作成功上报。 +一次运行的结果,由 `WorkflowRun.result` resolve。`value` 是脚本的物化返回值——纯宿主域 JSON 数据(脚本无返回值时为 `null`)——仅在 `completed` 时有意义。`stopReason` 是封闭联合类型(引擎所有;消费方可穷举):`completed` | `cancelled` | `error`。非 `completed` 的原因在 `error` 中携带失败信息,消费方将其映射为 `isError` 工具结果,而非把部分输出当作成功上报。 ```ts type-equiv interface WorkflowResult { @@ -50,7 +50,7 @@ interface WorkflowResult { ## 活跃运行:`WorkflowRun` -脚本执行期间消费方持有的句柄。消费方 await `result`,可在运行中途 `cancel`,且必须在每条路径上调用 `dispose`。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后永远卡住。`dispose()` = cancel + 有界 settle + 子 agent 静默;它不会因脚本卡死而挂起。 +脚本执行期间消费方持有的句柄。消费方 await `result`,可中途 `cancel`,且*必须*在每条路径上 `dispose`。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后卡死。`dispose()` = cancel + 有界 settle + 子 agent 静默;它不会因脚本卡死而挂起。 ```ts type-equiv interface WorkflowRun { @@ -64,8 +64,8 @@ interface WorkflowRun { ## 失败纪律:`WorkflowError.fatal` -脚本内部的钩子误用——错误参数、未知或延迟的 `agent()` 选项、超出[结构化输出子集](../../packages/core/tools/README.md)的 schema、触发的上限、seam 启动失败、取消——会抛出 `fatal: true` 的 `WorkflowError`。`parallel()`/`pipeline()` 组合器对 fatal 错误执行重新抛出,而非将该项映射为 `null`:一个拼写错误的选项必须大声杀死脚本,绝不能消融为看似普通子 agent 失败的东西。逐项的 `null` 保留给子运行失败(非 `completed` 的 stop reason)和阶段内的普通脚本错误。 +脚本内部的钩子误用:错误参数、未知或延迟的 `agent()` 选项、超出[结构化输出子集](../../packages/core/tools/README.md)的 schema、触发的上限、seam 启动失败、取消,都会抛出 `fatal: true` 的 `WorkflowError`。`parallel()`/`pipeline()` 组合器对 fatal 错误直接重新抛出,而非将该项映射为 `null`:一个拼写错误的选项必须让脚本大声失败,绝不能消融为看似普通子 agent 失败的结果。逐项的 `null` 保留给子运行失败(非 `completed` 的 stop reason)和阶段内的普通脚本错误。 ## 事件 -`workflow/*` 事件(`workflow/start`、`workflow/phase`、`workflow/log`、`workflow/agent-start`、`workflow/agent-end`、`workflow/end`——见[事件目录](../cordis-catalog/events.md))是**仅供观察**的 emit,携带数据快照:每个 payload 以 `WorkflowRunInfo`(id + meta)开头,从不暴露活跃的 `WorkflowRun`,因此订阅者无法获得 `cancel`/`dispose`;`workflow/end` 刻意省略 result value(观察结果的监听器不得收到调用方 result 的可变别名)。每次 emit 对每个监听器隔离:抛异常的订阅者被记录但不传播,不会饿死其后注册的监听器;每个监听器收到自己的 payload 克隆,因此修改它既不会损坏引擎也不会影响其他监听器。这种隔离与 `subagent/start`/`subagent/end` 一致。 +`workflow/*` 事件(`workflow/start`、`workflow/phase`、`workflow/log`、`workflow/agent-start`、`workflow/agent-end`、`workflow/end`,见[事件目录](../cordis-catalog/events.md))是**仅供观察**的 emit,携带数据快照:每个 payload 以 `WorkflowRunInfo`(id + meta)开头,而非活跃的 `WorkflowRun`,因此订阅者无法获得 `cancel`/`dispose`;`workflow/end` 刻意省略 result value(观察结果的监听器不得收到调用方 result 的可变别名)。每次 emit 对每个监听器隔离:抛出异常的订阅者被记录日志但不传播,不会饿死在它之后注册的监听器;每个监听器收到自己的 payload 克隆,因此修改它既不会损坏引擎也不会影响其他监听器。这种隔离方式与 `subagent/start`/`subagent/end` 一致。 diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index 9e6c34072b..f36c38cf0a 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0001-acp-default-export-drops-inject.md: 6a71d8d7ef72e3110a99774b180f3de7115ef622 -0001-acp-default-export-drops-inject.zh.md: 12bb3501a56c3cfef8f7a1b0d773be62db8e09ca +0001-acp-default-export-drops-inject.zh.md: 1d97f1140a9595ac7e304b5a1600c3cbc47292f1 diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index 12bb3501a5..1d97f1140a 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -6,23 +6,23 @@ Status: resolved (fix in PR #41 `feat/acp-2-bridge`) ## 摘要 -两个集成错误在单元测试全绿的情况下击溃了 ACP:一个 default export 导致 Loader 丢弃 `inject`,一个经过 traceable 代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复后新增了无需 API key 的真实 Loader 覆盖,以及关于插件导出和可选服务访问的包(package)规则。 +两个集成错误在单元测试全覆盖的情况下仍然导致 ACP(Agent Client Protocol)崩溃:一个 default export 使 Loader 丢弃了 `inject`,一个经 traceable 代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复方案增加了无需 API key 的真实 Loader 覆盖率,并为插件导出和可选服务访问制定了包(package)级规则。 ## 概述 -ACP 服务器(`examples/acp-agent`、`@deepseek-ai/dsh-acp`)在真实编辑器(Zed)连接的瞬间崩溃:第一个 `session/new` 请求返回 `Internal error: cannot get property "agents" without inject`,`session/load` 对 `sessionPersistence` 返回相同错误。尽管有 178 个绿色单元测试和 100% 行覆盖率,bridge 在生产环境中完全无法工作。两个独立的 bug 隐藏在同一个错误字符串背后,测试套件因同一个原因漏掉了二者:每个测试都通过一条不会触及插件实际加载方式或服务实际解析方式的路径来挂载插件。 +ACP 服务器(`examples/acp-agent`、`@deepseek-ai/dsh-acp`)在真实编辑器(Zed)连接的瞬间崩溃:第一个 `session/new` 请求返回 `Internal error: cannot get property "agents" without inject`,`session/load` 对 `sessionPersistence` 返回同样的错误。尽管有 178 个绿色单元测试和 100% 行覆盖率,bridge 在生产环境中完全无法工作。两个独立的 bug 隐藏在同一个错误字符串背后,测试套件之所以两个都没捕获,原因也相同:所有测试都通过一条不会触及插件真实加载方式和服务真实解析方式的路径来挂载插件。 ## 影响 -ACP 服务器无法创建或加载任何一个会话——这正是编辑器最先调用的两个 RPC。任何将 agent 接入 Zed 的人都会立即遇到硬性失败。无数据丢失(崩溃前没有持久化任何内容);代价完全是「功能不可用」加上两次定位原因的调试时间。 +ACP 服务器无法创建或加载任何一个会话——而这正是编辑器最先调用的两个 RPC。任何将 agent(智能体)接入 Zed 的人都会立即遭遇硬性失败。无数据丢失(崩溃前没有任何内容被持久化);代价完全是「功能不可用」加上两次定位原因的调试时间。 ## 时间线 -- Bridge(RFC 010)带着完整的单元测试套件(编解码、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试一起落地。全部绿色,100% 覆盖率。 -- 一次真实的 Zed 会话立即在 `session/new` 上失败,报错 `cannot get property "agents" without inject`。 -- 调查最初追踪的是 Cordis「traceable/shadow」理论(合理,且机制确实存在——见 Bug #2),随后在 vendor 的 `reflect.ts` 中对实际 fiber 遍历做了插桩,并运行了真实子进程。trace 显示 throw 发生在 `apply()` 第 179 行、**插件加载时**,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 -- 找到根因 #1:一行多余的 `export default apply`。移除后 `session/new` 修复。 -- 移除后暴露了 Bug #2:`session/load` 仍然在 `sessionPersistence` 上抛出——这是一个真正不同的机制(shadow 遍历),通过隔离修复并重新运行真实子进程得到确认。 +- bridge(RFC 010)落地时附带完整的单元测试套件(codec、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 +- 真实 Zed 会话在 `session/new` 上立即失败,报错 `cannot get property "agents" without inject`。 +- 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 的 `reflect.ts` 中对实际 fiber 遍历做了插桩,并运行了真实子进程。trace 显示 throw 发生在 `apply()` 第 179 行、**插件加载时**,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 +- 找到根因 #1:一行多余的 `export default apply`。删除后 `session/new` 修复。 +- 删除后暴露了 Bug #2:`session/load` 仍然在 `sessionPersistence` 上抛错——这是一个真正不同的机制(shadow 遍历),通过隔离修复并重新运行真实子进程得到确认。 ## 根因 #1——`export default apply` 丢弃了插件的 `inject`(导致 `session/new` 崩溃) @@ -36,7 +36,7 @@ export function apply(ctx: Context, config: AcpConfig): void { /* … */ } export default apply // ← the bug ``` -当插件从 `cordis.yml` 加载时,Cordis Loader 通过 `Loader.unwrapExports`(`vendor/loader/src/index.ts`)对导入的模块做规范化处理: +当插件从 `cordis.yml` 加载时,Cordis Loader 通过 `Loader.unwrapExports`(`vendor/loader/src/index.ts`)对导入的模块进行规范化: ```ts ignore-check unwrapExports(exports: any) { @@ -47,19 +47,19 @@ unwrapExports(exports: any) { } ``` -存在 default export 时,`exports.default ?? exports` 解析为**裸 `apply` 函数**。裸函数没有 `inject`、没有 `name`、没有 `Config` 属性——这些作为*兄弟*命名导出存在于模块命名空间上,而 unwrap 到 `.default` 把命名空间整个丢弃了。Loader 随后基于一个空的 `inject` 构建了插件的 fiber。 +存在 default export 时,`exports.default ?? exports` 解析为**裸 `apply` 函数**。裸函数没有 `inject`、没有 `name`、没有 `Config` 属性——这些作为*兄弟*命名导出存在于模块命名空间上,而 unwrap 到 `.default` 把整个命名空间丢弃了。Loader 随后基于空的 `inject` 构建了插件的 fiber。 因此 `apply` 在一个**没有注入任何服务**的 fiber 中运行。第一行 `const agents = ctx.agents` 遍历 fiber 树(ROOT → Include → Loader → ROOT),在所有 fiber 的 store 中都找不到 `agents`,到达根 fiber(`runtime === null`)后抛出 `cannot get property "agents" without inject`。崩溃发生在*加载时*,而非后续的请求处理器中——请求只是恰好触发了加载。 -**修复:**删除 `export default apply`。Loader 随后使用模块命名空间,正确识别 `inject`/`name`/`Config`,`apply` 在一个真正授予了声明服务的 fiber 中运行。 +**修复:** 删除 `export default apply`。Loader 随后使用模块命名空间,正确识别 `inject`/`name`/`Config`,`apply` 在一个真正授予了声明服务的 fiber 中运行。 -## 根因 #2——可选服务的属性读取在 traceable shadow 中触发 inject 守卫(导致 `session/load` 崩溃) +## 根因 #2——可选服务读取通过 traceable shadow 触发 inject 守卫(导致 `session/load` 崩溃) -修复 #1 后,`session/new` 正常工作,但 `session/load` 仍然抛出 `cannot get property "sessionPersistence" without inject`。这次*确实*是 Cordis 的 traceable/shadow 机制,值得精确理解。 +修复 #1 后,`session/new` 正常工作,但 `session/load` 仍然抛出 `cannot get property "sessionPersistence" without inject`。这个问题*确实*是 Cordis 的 traceable/shadow 机制,值得精确理解。 -`session/load` 调用 `agents.resume(...)`,后者委托给 `AgentLoop.resume()`,其中读取了 `this.ctx.sessionPersistence`。`AgentLoop` 的 `static inject` 故意**不**包含 `sessionPersistence`——注入它会导致非持久化的演示永远挂起,等待一个永远不会加载的后端。该服务由一个独立的兄弟插件/fiber 提供,按需读取。 +`session/load` 调用 `agents.resume(...)`,后者委托给 `AgentLoop.resume()`,其中读取了 `this.ctx.sessionPersistence`。`AgentLoop` 的 `static inject` 故意**不**包含 `sessionPersistence`——注入它会导致非持久化的演示永远挂起,等待一个永远不会加载的后端。该服务由一个独立的兄弟插件/fiber 提供,以机会性方式读取。 -Cordis 中的服务访问通过上下文代理(`vendor/cordis/src/reflect.ts`)进行。当通过从外部 fiber 获取的 *traceable 代理*调用服务方法时(此处:bridge fiber 调用 `ctx.agents.resume`,注册表返回 `this.factory`——即 `AgentLoop`——被重新包装为绑定到调用方的新 traceable 代理),`createShadowMethod`(`vendor/cordis/src/utils.ts`)将 `this` 重新绑定到一个 *shadow* 对象,其 `ctx` 携带 `[symbols.shadow]` 指向 `AgentLoop` 自身的构造上下文。在 `resume` 内部,`this.ctx.sessionPersistence` 的解析从 shadow 的 fiber 开始遍历: +Cordis 中的服务访问通过上下文代理(`vendor/cordis/src/reflect.ts`)进行。当通过从外部 fiber 获取的 *traceable 代理*调用服务方法时(此处:bridge fiber 调用 `ctx.agents.resume`,注册表返回 `this.factory`——即 `AgentLoop`——重新包装为绑定到调用方的新 traceable 代理),`createShadowMethod`(`vendor/cordis/src/utils.ts`)将 `this` 重新绑定到一个 *shadow* 对象,其 `ctx` 携带 `[symbols.shadow]` 指向 `AgentLoop` 自身的构造上下文。在 `resume` 内部,`this.ctx.sessionPersistence` 的解析从 shadow 的 fiber 开始遍历: ```ts ignore-check // reflect.ts get handler @@ -74,40 +74,40 @@ while (true) { } ``` -遍历**只走祖先方向**。`sessionPersistence` 既不在 `AgentLoop` 的 fiber store 中(不在其 `static inject` 里),也不在通往根的任何祖先上(它在一个*兄弟*分支上),因此遍历到达根 fiber 后抛出。 +遍历**仅向祖先方向**进行。`sessionPersistence` 既不在 `AgentLoop` 的 fiber store 中(不在其 `static inject` 中),也不在通往 root 的任何祖先上(它位于一个*兄弟*分支),因此遍历到达根 fiber 后抛错。 -为什么内存中的 `AgentLoop` resume 测试没有捕获到这个问题?因为它们从测试代码中直接调用 `ctx.agents.resume(...)`——*不在任何插件 fiber 内*。此时 `ctx.fiber.runtime` 为 `null`,代理处理器走了一条提前退出的路径: +为什么内存中的 `AgentLoop` resume 测试没有捕获这个问题?因为它们从测试代码直接调用 `ctx.agents.resume(...)`——*在任何插件 fiber 之外*。此时 `ctx.fiber.runtime` 为 `null`,代理处理器走了一条提前绕过的路径: ```ts ignore-check if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct global-store lookup, no fiber walk ``` -`ctx.reflect.get(name, false)` 是基于 isolate symbol 的全局服务 store 直接查找——完全忽略 fiber 拓扑,能找到服务。因此从顶层测试读取正常;从真实插件 fiber 内部、经由 shadow 到达时则抛出。bridge 恰好是后者。 +`ctx.reflect.get(name, false)` 是基于 isolate symbol 的全局服务 store 直接查找——完全忽略 fiber 拓扑,能找到服务。因此从顶层测试读取可以成功;而从真实插件 fiber 内部、经由 shadow 到达时则抛错。bridge 恰好是后者。 -**修复:**使用 `ctx.get('sessionPersistence')` 读取可选服务,该方法使用全局 isolate-keyed store,同时保留活跃状态检查。对于插件声明注入集中的服务,直接属性读取仍然适用。 +**修复:** 使用 `ctx.get('sessionPersistence')` 读取可选服务,该方法使用全局 isolate-keyed store 同时保留活跃状态检查。对于插件声明注入集中的服务,直接属性读取仍然适用。 -## 为什么所有测试都漏掉了(真正的失败) +## 为什么所有测试都没有捕获(真正的失败) -两个 bug 共享同一个流程缺口:**没有任何测试通过插件的真实加载路径或真实调用拓扑来运行它。** +两个 bug 共享同一个流程缺口:**没有任何测试通过插件的真实加载路径或真实调用拓扑来驱动它。** -- 内存 harness 通过手动构建插件对象来挂载 bridge:`ctx.plugin({ name, inject, apply })`。这手动提供了 `inject`,因此永远无法复现 Bug #1——`unwrapExports` 只被 *Loader* 调用,`ctx.plugin` 从不调用它。即使 `ctx.plugin(NamespaceImport)` 也无法捕获此问题。 -- 同一个 harness 把所有东西平铺挂载在一个根上下文上,因此从中触达的 `AgentLoop` resume 要么在顶层运行(`!runtime` 旁路),要么通过一个 origin 仍在根上解析的 shadow——掩盖了 Bug #2 的祖先遍历失败。 -- 唯一的无 key e2e 发送 `initialize` 并检查 stdout 纯净性。`initialize` 从不触达 factory,因此安然通过两个 bug。 -- 唯一驱动 `session/new`/`session/load` 的测试需要 key 才能运行,CI(无 key)跳过了它——而本地它之所以「通过」,只是因为一个陈旧的已构建 `lib/`(包含旧代码)恰好满足了模块解析。 +- 内存 harness 通过手动构建插件对象来挂载 bridge:`ctx.plugin({ name, inject, apply })`。这手动提供了 `inject`,因此永远无法复现 Bug #1——`unwrapExports` 只被 *Loader* 调用,`ctx.plugin` 从不调用它。即使 `ctx.plugin(NamespaceImport)` 也无法捕获。 +- 同一个 harness 将所有内容平铺挂载在一个根上下文上,因此从中触达的 `AgentLoop` resume 要么运行在顶层(`!runtime` 绕过),要么通过一个 origin 仍然解析在 root 上的 shadow——掩盖了 Bug #2 的祖先遍历失败。 +- 唯一的无 key e2e 发送 `initialize` 并检查 stdout 纯净性。`initialize` 从不触达 factory,因此两个 bug 都安然通过。 +- 唯一驱动 `session/new`/`session/load` 的测试需要 key 才能运行,因此 CI(无 key)跳过了它——而本地它之所以「通过」,只是因为一个陈旧的已构建 `lib/`(包含旧代码)恰好满足了模块解析。 -100% 行覆盖率自始至终满足。覆盖率证明代码行*被执行过*;它不能说明功能是否*以交付的方式*工作。 +100% 行覆盖率始终满足。覆盖率证明代码行*被执行过*;它不能说明功能是否*按交付方式正常工作*。 ## 新增的防护措施 -- **移除 `export default apply`**(`packages/ui/acp/src/index.ts`)——Bug #1 的修复。 +- **删除 `export default apply`**(`packages/ui/acp/src/index.ts`)——Bug #1 的修复。 - **`AgentLoop.resume` 使用 `this.ctx.get('sessionPersistence')`**(`packages/core/agent-loop/src/index.ts`)——Bug #2 的修复,附注释说明 shadow 遍历陷阱。 - **无需 key 的 `session/new` e2e,通过真实 stdio 运行**(`examples/acp-agent/tests/acp.e2e.ts`):以子进程方式通过真实 Loader 启动示例,并断言 `session/new` 正常返回。无需 API key 即可在 Bug #1 上大声失败。已验证恢复 `export default apply` 时测试失败。 -- **e2e spawn 中设置 `TSX_TSCONFIG_PATH`**:子进程从临时 cwd 运行,tsx 无法通过向上搜索找到仓库根的 tsconfig `paths` 映射——因此 dsh-* 的导入静默回退到已构建的 `lib/`。将 tsx 指向仓库 tsconfig 使解析不依赖 cwd,确保测试运行的是*源码*而非可能陈旧的构建产物。 -- **[docs/testing.md](../testing.md) 规则**:「测试真实入口路径」,行覆盖率不等于行为覆盖率——将此教训编纂为所有未来插件的规则。 +- **e2e spawn 中设置 `TSX_TSCONFIG_PATH`**:子进程从临时 cwd 运行,tsx 无法通过向上搜索找到仓库根的 tsconfig `paths` 映射——因此 dsh-* 的 import 静默回退到已构建的 `lib/`。将 tsx 指向仓库 tsconfig 使解析不依赖 cwd,确保测试运行的是*源码*而非可能陈旧的构建产物。 +- **[docs/testing.md](../testing.md) 规则**:「测试真实入口路径」,行覆盖率不等于行为覆盖率——将这一教训编纂为所有未来插件的规则。 -## 教训 +## 经验教训 - 命名空间插件与 default export 在 Cordis Loader 下互斥。选择命名空间形式(`name`/`inject`/`Config`/`apply`),不要添加 `export default`——`unwrapExports` 会丢弃命名空间。 -- 对于插件按需读取但**不**声明在 `static inject` 中的服务,使用 `ctx.get(name)`,绝不使用 `ctx.<name>`。属性代理通过只走祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认严格——后端未激活时返回 `undefined`,而非在 teardown 过程中把半拆除的实例交出去)。 -- 手动构造插件的测试无法验证插件的加载方式。至少一个测试必须端到端地驱动真实的 Loader/export 路径。当核心操作不调用模型时,该测试无需 API key——因此它属于 CI,而非 key 门控之后。 -- 相信 trace,不要相信理论。优雅的 shadow 解释是真实的,但它是*第二个* bug;*第一个*是一行导出错误,在数小时合理但错误的推理之后,一条 fiber 遍历的 `console.error` 几分钟就找到了它。 +- 对于插件机会性读取但**未**在 `static inject` 中声明的服务,使用 `ctx.get(name)`,绝不使用 `ctx.<name>`。属性代理通过仅向祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认严格——非活跃后端读取为 `undefined`,而非在 teardown 过程中被交出)。 +- 手动构建插件的测试无法验证插件的加载方式。至少一个测试必须端到端地驱动真实的 Loader/export 路径。当核心操作不调用模型时,该测试无需 API key——因此它属于 CI,而非 key 门控之后。 +- 相信 trace,不要相信理论。优雅的 shadow 解释是真实的,但它是*第二个* bug;*第一个*是一行导出错误,在数小时看似合理但实际错误的推理之后,一个 fiber 遍历的 `console.error` 在几分钟内就找到了它。 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index f59f7794de..533c899345 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0002-js-expression-disabled-filesystem-tools.md: 43e57a6bd1b68f38c47eeda3c3abb8455024b350 -0002-js-expression-disabled-filesystem-tools.zh.md: e54431b7f4061bb4bdc22a37f0651697c6247dda +0002-js-expression-disabled-filesystem-tools.zh.md: 35bd54bb6f1551b5927c00184306141417803eaf diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index e54431b7f4..35bd54bb6f 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -4,44 +4,44 @@ Status: resolved +## 概要 + +ACP(Agent Client Protocol)示例试图通过 `disabled: !!js ...` 有条件地启用文件系统插件,但 Cordis 仅在插件 `config` 内部对 JavaScript 表达式求值。原始的表达式对象为 truthy,因此文件系统栈始终处于禁用状态。快照刷新随后将 `UNKNOWN_TOOL` 结果接受为新的 golden 基准。修复方案改用显式的文件系统 overlay,并增加了静态配置守卫和快照结果守卫。 + ## 摘要 -ACP 示例试图通过 `disabled: !!js ...` 有条件地启用文件系统插件,但 Cordis 仅在插件 `config` 内部求值 JavaScript 表达式。原始的表达式对象为 truthy,因此文件系统栈始终处于禁用状态。快照刷新随后将 `UNKNOWN_TOOL` 结果作为新的 golden 接受。修复方案使用显式的文件系统 overlay,并增加了静态配置守卫和快照结果守卫。 +默认的 ACP 组合有意只启用 bash,因为其沙箱无法约束进程内的文件系统提供方。文件系统快照场景仍然需要 `read`、`write` 和 `edit`,因此这些插件被放在默认的 `cordis.yml` 中,并附带一个 `disabled` 表达式,意图仅在全权限启动和快照模式下启用它们。 -## 概述 - -默认的 ACP 组合有意仅包含 bash,因为其沙箱无法约束进程内的文件系统提供方。文件系统快照场景仍需要 `read`、`write` 和 `edit`,因此这些插件被放入默认的 `cordis.yml`,并附带一个 `disabled` 表达式,意图仅在全权限启动和快照模式下启用它们。 - -Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader 递归地对插件的 `config` 进行了插值,但直接消费了 `disabled` 等入口元数据。因此每个文件系统入口都看到一个 truthy 对象,在所有模式下均保持禁用。 +Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader 递归地对插件的 `config` 进行插值,但直接消费 `disabled` 等入口元数据。因此每个文件系统入口看到的都是一个 truthy 对象,在所有模式下均保持禁用。 ## 影响 -七个文件系统场景和一个混合工作区编辑场景调用了注册表中不存在的工具。它们的结构化会话日志携带 `ToolNotFoundError`(code 为 `UNKNOWN_TOOL`),stdout 则渲染了通用的失败工具卡片。快照套件通过了,因为两个表面都与刷新后的 fixture(测试前置数据)匹配;它证明的是回归的确定性回放,而非文件系统行为的正确性。 +七个文件系统场景和一个混合工作区编辑场景调用了注册表中不存在的工具。其结构化会话日志携带 `ToolNotFoundError`(code 为 `UNKNOWN_TOOL`),stdout 渲染出通用的失败工具卡片。快照套件通过了,因为两个表面都与刷新后的 fixture(测试前置数据)匹配;它证明的是回归的确定性回放,而非文件系统行为的正确性。 -实际运行的受限默认组合并未获得意外的文件系统访问。一个朴素的插值修复反而会引入该风险:权限预设在运行时更新 bash 沙箱和审批状态,但无法挂载、卸载或约束文件系统栈。 +实际运行的受限默认模式并未获得意外的文件系统访问权限。一个简单的插值修复反而会制造该风险:权限预设在运行时更新 bash 沙箱和审批状态,但无法挂载、卸载或约束文件系统栈。 ## 时间线 - PR #261 整合了 ACP 组合并刷新了文件系统快照,同时引入了条件式文件系统入口。 - 所有单元测试、覆盖率、快照、文档、构建和 hygiene 检查均通过。 - 对刷新后的文件系统 golden 的评审发现了通用的失败卡片和结构化的 `UNKNOWN_TOOL` 结果。 -- 一次真实的 Loader 启动确认:每个 `disabled` 值仍然是表达式对象,每个文件系统 fiber 均未注册。 +- 一次真实的 Loader 启动确认:每个 `disabled` 值仍为表达式对象,每个文件系统 fiber 均未创建。 ## 根因 -实现方假设 `!!js` 适用于整个 Loader 入口。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不做插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 +实现时假设 `!!js` 适用于整个 Loader 入口。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 -快照框架将任何确定性的 transcript(文本记录)视为有效行为。Header pin 验证了组合后的工具 schema,但文件系统场景共享了来自默认组合的 pin,因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前,就已重写了预期的 stdout 和会话日志。 +快照框架将任何确定性的 transcript(文本记录)视为有效行为。Header pin 验证了组合后的工具 schema,但文件系统场景共享来自默认组合的 pin,因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前,就已重写了预期的 stdout 和会话日志。 -## 新增的防护措施 +## 已添加的防护措施 - 文件系统场景启动 `fs.cordis.yml`:一个显式的固定全权限 overlay,配有对应的 replay 配置和独立的 request-header 类。 -- [`AGENTS.md`](../../AGENTS.md) 和 [Cordis 入门](../cordis-primer.md#loader-configuration) 明确说明 `!!js` 仅在插件 `config` 下有效,条件式组合应使用 overlay。 -- `verify-cordis-config` 解析仓库中的 Cordis YAML,拒绝 Loader 入口元数据(包括 include patch 和插入的入口)中出现表达式节点。 -- `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,阻止其成为被接受的 golden。 +- [`AGENTS.md`](../../AGENTS.md) 与 [Cordis 入门](../cordis-primer.md#loader-configuration)明确说明 `!!js` 仅在插件 `config` 内有效,条件式组合应使用 overlay。 +- `verify-cordis-config` 解析仓库中的 Cordis YAML,拒绝 Loader 入口元数据中的表达式节点(包括 include patch 和插入的入口)。 +- `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,防止其成为被接受的 golden 基准。 ## 教训 - 语法上被接受的配置值不一定在该位置被求值;应记录并验证插值边界。 -- 快照刷新是 fixture 生产,不是正确性评审。像「已注册工具缺失」这样的语义不可能性需要独立于 golden 的断言。 -- 权限控制只应描述它实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。 +- 快照刷新是 fixture 的生产过程,不是正确性审查。诸如已注册工具缺失这类语义上不可能的结果,需要独立于 golden 的断言。 +- 权限控制只应描述其实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index f57d280d36..9fd5399786 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 4dc59e4f5e70f51c4c0baa64fbe34b213f2a7c3d -README.zh.md: 7e2d05d429b2521e7e772956b7644740d4642fac +README.zh.md: e9ea00dacf3fde6f4d04c9a3b6dd5beeb7929660 diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 7e2d05d429..e9ea00dacf 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -事故记录:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),有意义的部分是**为什么我们的流程放过了它**,而不仅仅是那行修复。 +事件复盘:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 -事后分析不是 [RFC](../rfc/README.md)(RFC 记录的是经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份面向过去的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体护栏使同类 bug 下次能快速失败。 +事后分析不是 [RFC](../rfc/README.md)(RFC 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露。 -满足以下条件时写一篇:bug **隐蔽**(机制不显而易见,一位细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性手误)、**重新发现的代价高**(它消耗了真实的调试时间,而且下次还会)。请链接该事后分析所推动建立的护栏(测试、AGENTS.md 规则、ADR)。 +当一个 bug 满足以下条件时,请撰写事后分析:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事后分析所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 -每篇事后分析以一段 **Executive summary** 开头:一段简短的文字,让忙碌的读者在三十秒内了解全貌——什么坏了、用通俗语言说的根因、为什么逃逸了、以及持久的教训——之后再展开详细的 Summary / Timeline / Root cause / Guardrails 各节。 +每篇事后分析以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、持久的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 | # | 标题 | |---|---| -| [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | -| [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | +| [0001](0001-acp-default-export-drops-inject.md) | ACP 服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | +| [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 | diff --git a/docs/rfc/README.i18n.yaml b/docs/rfc/README.i18n.yaml index 5bda02fc38..8d007fe169 100644 --- a/docs/rfc/README.i18n.yaml +++ b/docs/rfc/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 9014579f3a98be907885332a0c815bca5c96855c -README.zh.md: b51343b35aa04830b694f560ca9ae490995dcd43 +README.zh.md: cf258dc9ae1d73bf89d6a82c9bf246152c15e8b4 diff --git a/docs/rfc/README.zh.md b/docs/rfc/README.zh.md index b51343b35a..cf258dc9ae 100644 --- a/docs/rfc/README.zh.md +++ b/docs/rfc/README.zh.md @@ -2,48 +2,48 @@ [English](README.md) | 中文 -这里存放一类设计文档。**RFC** 记录塑造本代码库的决策或提案——代码和文档本身无法承载的*为什么*以及*放弃了什么*。完整列表见生成的 [INDEX.md](INDEX.md);本文是契约——RFC 放在哪里、何时该写,以及[文件内格式](#the-file-format)。 +这里存放一类设计文档。**RFC** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。完整列表见生成的 [INDEX.md](INDEX.md);本文件是契约:RFC 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 ## 布局与命名 -每篇 RFC 有两个轴,都编码在其**路径**中——`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`: +每份 RFC 有两个维度,都编码在其**路径**中:`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`。 -- **生命周期**(顶层文件夹)是 RFC 的状态,RFC 随状态变更在文件夹间移动: - - **`proposed/`**——实现前评审的提案;尚未构建(或仅部分构建)。 - - **`implemented/`**——决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后来移动了文件、重命名了包(package)或更改了键/默认值时,RFC 在同一个变更中更新以匹配(仅限事实——路径、名称、结构——不涉及决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 - - **`rejected/`**——提案经考虑后被否决。保留以备查阅,避免同一问题被反复争论。 -- **分类**(嵌套文件夹)是决策的*类型*——见下方[分类](#classification)。 +- **生命周期**(顶层文件夹)是 RFC 的状态,RFC 随状态变化在文件夹之间移动: + - **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。 + - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,RFC 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 + - **`rejected/`**:提案经过讨论后被否决。保留以备查阅,避免同一问题被反复争论。 +- **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。RFC 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 ## 分类 -每篇 RFC 归属于 `scripts/rfc-index.ts` 中封闭集合里的一个路径编码分类;分类门禁拒绝其他文件夹。[INDEX.md](INDEX.md) 由路径、标题和文件名日期生成,其新鲜度受门禁保护。新增分类需要同时更新规范集合与本节。见[分类 RFC](implemented/process/2026-06-20-rfc-classification.md) 与[索引生成 RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md)。 +每份 RFC 属于 `scripts/rfc-index.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。[INDEX.md](INDEX.md) 由路径、标题和文件名日期生成,其新鲜度受门禁保护。新增类别需要同时更新规范集合与本节。见[分类 RFC](implemented/process/2026-06-20-rfc-classification.md) 与[索引生成 RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md)。 -| 分类 | 涵盖内容 | +| 类别 | 覆盖范围 | |---|---| | `feature` | 面向用户或模型的新能力。 | -| `bug-fix` | 修正缺陷或填补事后复盘暴露的空白。 | -| `simplification` | 在不增加能力的前提下移除代码、行为或接口面。 | -| `architecture` | 关于**交付源码**的结构性决策——包之间的关系、运行时词汇。 | -| `process` | 围绕代码的工具、政策或工作流——门禁、包管理器、vendor 化——而非运行时行为。 | +| `bug-fix` | 修正缺陷或弥补事后复盘发现的缺口。 | +| `simplification` | 在不增加能力的前提下移除代码、行为或对外表面积。 | +| `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 | +| `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 | | `testing` | 测试基础设施与策略。 | -`architecture` 与 `process` 的分界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。(`refactor` 被刻意省略——它与 `simplification` 重叠,后者的判别标准「可观测行为是否改变」已覆盖了它。) +`architecture` 与 `process` 的界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。(`refactor` 被有意排除:它与 `simplification` 重叠,而后者的判别标准「可观察行为是否改变」已经覆盖了它。) -## 何时该写 +## 何时需要写一份 -当一个决策**持久**(它塑造代码库的范围超出单个函数或包)、**有争议**(存在一个合理工程师可能选择的真实替代方案)、且**令人意外**(未来读者否则会问「为什么要这样做」)时,请写一篇 RFC。对未来大量工作的提案从 `proposed/` 开始;已做出的决策从 `implemented/` 开始。选择与决策匹配的分类文件夹(见[分类](#classification))。 +当一个决策具备以下三个特征时,请写一份 RFC:**持久性**(它的影响超出单个函数或包)、**争议性**(存在一个合理工程师可能选择的真实替代方案)、**意外性**(未来读者否则会问「为什么要这样做」)。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -以下情况**不要**写 RFC:机械性或局部的选择(变量名、单文件重构);已由门禁或 AGENTS.md 中的约定强制并解释的事项;代码中标记为 `TODO(...)` 的暂定决策——将其记为 TODO,待尘埃落定后再提升为 RFC。RFC 永远不会被编辑成*另一个决策*:用新 RFC 取代旧的并互相链接。(编辑 `implemented/` RFC 以跟踪其已做出的决策现在*位于何处*——移动的文件、重命名的包——不是另一个决策,是必须做的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。) +以下情况**不要**写 RFC:机械性或局部的选择(一个变量名、一次单文件重构);已由门禁或 AGENTS.md 中的约定强制执行并解释的事项;代码中标记为 `TODO(...)` 的临时决策——将其记为 TODO,待稳定后再升级为 RFC。RFC 永远不会被编辑为一个*不同的决策*:用新 RFC 取代旧的,并互相链接。(编辑 `implemented/` RFC 以跟踪其已做出的决策现在*位于*何处——移动的文件、重命名的包——不是不同的决策,这是必需的而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。) ## 文件格式 -每篇 RFC 遵循统一的文件内格式,由 `pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../scripts/verify-rfc-format.ts),doc-sync(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 RFC](implemented/process/2026-07-05-uniform-rfc-format.md)。 +每份 RFC 遵循统一的文件内格式,由 `pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../scripts/verify-rfc-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 RFC](implemented/process/2026-07-05-uniform-rfc-format.md)。 ### 头部块 -每篇 RFC 的前三行严格为: +每份 RFC 的前三行严格为: ```markdown # RFC: <title> @@ -51,17 +51,17 @@ Status: <status> ``` -后接一个空行。`Status:` 的值有三种形式,且必须与文件所在的生命周期文件夹一致——门禁会交叉检查: +后跟一个空行。`Status:` 的值有三种形式,且必须与文件所在的生命周期文件夹一致——门禁会交叉检查: - `Status: proposed` - `Status: implemented` - `Status: rejected — <why, in one line>` -状态行不带日期、不带括号补充说明:文件名承载首次提出日期,git 承载其余一切,「以修订形式接受」之类的说明属于正文内容(在陈述决策的地方说明修订)。否决原因是唯一带内容的状态行,因为读者查阅被否决 RFC 时要的就是结论。 +状态行不带日期、不带括号补充说明:文件名记录首次提出日期,git 记录其余一切;「以修订形式接受」之类的说明属于正文内容(在陈述决策的地方说明修订)。拒绝原因是唯一带内容的状态,因为读者查阅被否决的 RFC 时,结论正是他们要找的。 ### 正文骨架 -每篇 RFC 的正文以 `## Problem` 开头——动机,写法应独立于解决方案。后续内容取决于生命周期;重复出现的章节使用以下规范名称且仅限这些名称,而真正特有的技术章节(包拓扑、协议格式(wire format)、schema)在必需章节之间自由编排。 +每份 RFC 的正文以 `## Problem` 开头:动机,写法上不依赖解决方案即可独立成文。后续内容取决于生命周期;固定章节使用以下规范名称且仅限这些名称,而真正独特的技术章节(包拓扑、协议契约、schema 等)在必需章节之间可自由组织。 #### `proposed/` @@ -74,7 +74,7 @@ Status: <status> ## Risks ``` -`## Proposal` 是拟议的变更,可以正当地使用将来时——计划、迁移步骤和未决问题在工作尚未构建时属于此处。`## Acceptance criteria` 说明什么可观测状态意味着完成。`## Risks` 涵盖可能出错的事项以及变更有意放弃的东西。 +`## Proposal` 描述拟议的变更,可以合理地使用将来时态——计划、迁移步骤和待解决问题在工作尚未完成时属于此处。`## Acceptance criteria` 说明什么可观察状态意味着完成。`## Risks` 涵盖可能出错的事项以及该变更有意放弃的东西。 #### `implemented/` @@ -86,26 +86,26 @@ Status: <status> ## Consequences ``` -`## Decision` 以现在时描述已交付的现实,整个文件按 [implemented/AGENTS.md](implemented/AGENTS.md) 的要求与之保持同步。`## Consequences` 记录权衡的代价**与**收益。提案阶段的标题在这里属于规格用语,门禁会拒绝:`## Proposal`、`## Plan`、`## Migration plan` 和 `## Acceptance criteria` 不得出现在 implemented RFC 中([slop 检查清单](../AGENTS.md)说明了原因)。`## Testing`、`## Deferred` 或 `## Related` 章节在陈述现在时事实时是允许的。 +`## Decision` 以现在时态描述已交付的现实,整个文件按 [implemented/AGENTS.md](implemented/AGENTS.md) 的要求与之保持同步。`## Consequences` 记录权衡的代价**与**收益。提案阶段的标题在此属于规格用语,门禁会拒绝它们:`## Proposal`、`## Plan`、`## Migration plan` 和 `## Acceptance criteria` 不得出现在 implemented RFC 中(原因见 [slop 检查清单](../AGENTS.md))。`## Testing`、`## Deferred` 或 `## Related` 章节在陈述现在时态的事实时是允许的。 #### `rejected/` -被否决的 RFC 是冻结的提案:保留其提案时的所有章节(包括 `## Acceptance criteria` 或 `## Plan`),结论写在 `Status:` 行。仅头部块、`## Problem` 开头、`## Proposal` 章节,以及下方的「曾考虑的替代方案」强制要求适用。 +被否决的 RFC 是冻结的提案:保留提案时的所有章节(包括 `## Acceptance criteria` 或 `## Plan`),结论写在 `Status:` 行上。仅头部块、`## Problem` 开头、`## Proposal` 章节以及下方的「曾考虑的替代方案」强制要求适用。 -### 曾考虑的替代方案——强制要求 +### 曾考虑的替代方案——必需 -每篇 RFC 都必须有一个 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案一段(加粗引导),或对争议较大的方案使用 `### Why not <X>?` 子章节。记录决策却不记录它击败了什么,就是在邀请反复争论——正是 RFC 存在的目的所要防止的。 +每份 RFC 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是 RFC 存在的意义所要防止的。 -替代方案是记录下来的,而非凭空编造的。日期早于 2026-07-05 的 RFC,如果其替代方案无法从记录中重建,则在该章节位置放置以下精确注释,门禁仅对格式前文件接受此注释: +替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 RFC,在该章节位置放置以下精确注释,门禁仅对格式规范之前的文件接受此注释: ```markdown <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> ``` -### 在生命周期间移动 +### 在生命周期之间移动 -将文件在生命周期文件夹间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折叠进 `## Consequences`(或一个现在时的 `## Testing`/`## Verification` 章节,用于说明现在什么在固定该行为),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 要求的改写,使之机械化。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。 +将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写,使之机械化。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。 ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 契约](../i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# RFC: ` 和 `Status:` 行)保持英文原样不变。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 +`.zh.md` 对侧文件按 [i18n 契约](../i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# RFC: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 From 8ea5cdd894d9db4680f83644b214faebd9df535d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:07:36 -0700 Subject: [PATCH 007/113] docs(i18n): re-translate RFC batch with the prompt-v4 pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 146 篇 RFC 译文按 v4 基线(#348)重出:v4 模板+术语表、金标 few-shot、三段协议、切换行后处理;全量机械核对零异常(一处 task id 术语违规已修)。三篇超长 RFC(code-mode 已入,web-seam/ agent-scope/sandbox/cds-core 仍在长文档通道产出)随后补。 --- ...6-06-11-content-block-vocabulary.i18n.yaml | 2 +- .../2026-06-11-content-block-vocabulary.zh.md | 18 +-- .../2026-06-11-custom-schema-dsl.i18n.yaml | 2 +- .../2026-06-11-custom-schema-dsl.zh.md | 12 +- ...ev-invariants-over-deep-readonly.i18n.yaml | 2 +- ...11-dev-invariants-over-deep-readonly.zh.md | 34 +++--- ...026-06-11-event-sourced-sessions.i18n.yaml | 2 +- .../2026-06-11-event-sourced-sessions.zh.md | 18 +-- ...06-11-microkernel-event-taxonomy.i18n.yaml | 2 +- ...026-06-11-microkernel-event-taxonomy.zh.md | 24 ++-- ...026-06-11-runtime-arg-validation.i18n.yaml | 2 +- .../2026-06-11-runtime-arg-validation.zh.md | 12 +- ...-06-11-structured-error-taxonomy.i18n.yaml | 2 +- ...2026-06-11-structured-error-taxonomy.zh.md | 16 +-- ...-tool-schemas-in-prompt-assembly.i18n.yaml | 2 +- ...6-11-tool-schemas-in-prompt-assembly.zh.md | 12 +- .../2026-06-13-capability-seams.i18n.yaml | 2 +- .../2026-06-13-capability-seams.zh.md | 28 ++--- .../2026-06-13-twin-llm-adapters.i18n.yaml | 2 +- .../2026-06-13-twin-llm-adapters.zh.md | 18 +-- .../2026-06-14-session-persistence.i18n.yaml | 2 +- .../2026-06-14-session-persistence.zh.md | 28 ++--- ...6-06-15-turn-enclosure-invariant.i18n.yaml | 2 +- .../2026-06-15-turn-enclosure-invariant.zh.md | 36 +++--- ...06-17-filesystem-capability-seam.i18n.yaml | 2 +- ...026-06-17-filesystem-capability-seam.zh.md | 106 ++++++++-------- ...nt-lifecycle-and-ownership-seams.i18n.yaml | 2 +- ...-agent-lifecycle-and-ownership-seams.zh.md | 26 ++-- .../2026-06-18-session-surface.i18n.yaml | 2 +- .../2026-06-18-session-surface.zh.md | 46 +++---- ...ed-persistence-write-coordinator.i18n.yaml | 2 +- ...shared-persistence-write-coordinator.zh.md | 38 +++--- .../2026-06-20-branded-ids.i18n.yaml | 2 +- .../architecture/2026-06-20-branded-ids.zh.md | 46 +++---- ...-20-extract-example-app-packages.i18n.yaml | 2 +- ...6-06-20-extract-example-app-packages.zh.md | 52 ++++---- .../2026-06-20-package-hierarchy.i18n.yaml | 2 +- .../2026-06-20-package-hierarchy.zh.md | 38 +++--- ...andatory-app-attribution-headers.i18n.yaml | 2 +- ...21-mandatory-app-attribution-headers.zh.md | 74 ++++++------ ...06-26-file-context-as-event-gate.i18n.yaml | 2 +- ...026-06-26-file-context-as-event-gate.zh.md | 92 +++++++------- ...stdin-env-trusted-plugin-surface.i18n.yaml | 2 +- ...ash-stdin-env-trusted-plugin-surface.zh.md | 22 ++-- ...026-06-30-event-domain-semantics.i18n.yaml | 2 +- .../2026-06-30-event-domain-semantics.zh.md | 28 ++--- .../2026-07-02-fs-per-session-cwd.i18n.yaml | 2 +- .../2026-07-02-fs-per-session-cwd.zh.md | 28 ++--- ...2-result-time-applied-hunk-diffs.i18n.yaml | 2 +- ...07-02-result-time-applied-hunk-diffs.zh.md | 46 +++---- ...6-07-02-tool-render-intent-union.i18n.yaml | 2 +- .../2026-07-02-tool-render-intent-union.zh.md | 48 ++++---- ...ilesystem-directory-listing-seam.i18n.yaml | 2 +- ...03-filesystem-directory-listing-seam.zh.md | 44 +++---- ...bles-and-tool-guidance-ownership.i18n.yaml | 2 +- ...ariables-and-tool-guidance-ownership.zh.md | 64 +++++----- ...6-07-05-reconstructable-requests.i18n.yaml | 2 +- .../2026-07-05-reconstructable-requests.zh.md | 46 +++---- ...bagent-provider-lifecycle-events.i18n.yaml | 2 +- ...5-subagent-provider-lifecycle-events.zh.md | 30 ++--- ...6-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 52 ++++---- ...6-07-07-tool-call-timeout-policy.i18n.yaml | 2 +- .../2026-07-07-tool-call-timeout-policy.zh.md | 66 +++++----- .../2026-07-08-agent-scope-contexts.i18n.yaml | 2 +- .../2026-07-08-agent-scope-contexts.zh.md | 64 +++++----- ...-06-14-acp-agent-client-protocol.i18n.yaml | 2 +- ...2026-06-14-acp-agent-client-protocol.zh.md | 46 +++---- .../2026-06-14-acp-multi-session.i18n.yaml | 2 +- .../2026-06-14-acp-multi-session.zh.md | 30 ++--- .../feature/2026-06-15-code-mode.i18n.yaml | 2 +- .../feature/2026-06-15-code-mode.zh.md | 114 +++++++++--------- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 2 +- .../2026-06-17-filesystem-tool-schemas.zh.md | 74 ++++++------ ...-acp-terminal-and-tool-rendering.i18n.yaml | 2 +- ...6-18-acp-terminal-and-tool-rendering.zh.md | 40 +++--- ...06-18-compaction-capability-seam.i18n.yaml | 2 +- ...026-06-18-compaction-capability-seam.zh.md | 90 +++++++------- ...6-06-21-subagent-capability-seam.i18n.yaml | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 54 ++++----- .../2026-06-22-acp-subagent-backend.i18n.yaml | 2 +- .../2026-06-22-acp-subagent-backend.zh.md | 36 +++--- .../2026-06-25-ask-user-question.i18n.yaml | 2 +- .../2026-06-25-ask-user-question.zh.md | 34 +++--- .../2026-06-29-todo-write-tool.i18n.yaml | 2 +- .../feature/2026-06-29-todo-write-tool.zh.md | 48 ++++---- .../feature/2026-06-30-hook-bridges.i18n.yaml | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 50 ++++---- .../2026-06-30-hook-protocol-lib.i18n.yaml | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 24 ++-- .../2026-06-30-interception-seams.i18n.yaml | 2 +- .../2026-06-30-interception-seams.zh.md | 44 +++---- ...026-06-30-session-store-fork-api.i18n.yaml | 2 +- .../2026-06-30-session-store-fork-api.zh.md | 26 ++-- ...26-06-30-subagent-observe-enrich.i18n.yaml | 2 +- .../2026-06-30-subagent-observe-enrich.zh.md | 20 +-- .../2026-07-05-dynamic-workflows.i18n.yaml | 2 +- .../2026-07-05-dynamic-workflows.zh.md | 70 +++++------ .../feature/2026-07-05-skill-system.i18n.yaml | 2 +- .../feature/2026-07-05-skill-system.zh.md | 40 +++--- .../2026-07-06-approval-seam.i18n.yaml | 2 +- .../feature/2026-07-06-approval-seam.zh.md | 92 +++++++------- .../2026-07-06-explicit-tool-order.i18n.yaml | 2 +- .../2026-07-06-explicit-tool-order.zh.md | 52 ++++---- .../2026-07-07-mcp-client-plugin.i18n.yaml | 2 +- .../2026-07-07-mcp-client-plugin.zh.md | 106 ++++++++-------- .../2026-07-07-session-prefix.i18n.yaml | 2 +- .../feature/2026-07-07-session-prefix.zh.md | 40 +++--- .../2026-07-08-repeat-tool-guard.i18n.yaml | 2 +- .../2026-07-08-repeat-tool-guard.zh.md | 56 ++++----- ...-self-referential-cordis-toolset.i18n.yaml | 2 +- ...7-08-self-referential-cordis-toolset.zh.md | 66 +++++----- ...2026-07-10-session-query-service.i18n.yaml | 2 +- .../2026-07-10-session-query-service.zh.md | 32 ++--- ...nt-persona-tool-filter-and-depth.i18n.yaml | 2 +- ...bagent-persona-tool-filter-and-depth.zh.md | 78 ++++++------ .../2026-06-11-doc-sync-enforcement.i18n.yaml | 2 +- .../2026-06-11-doc-sync-enforcement.zh.md | 26 ++-- .../2026-06-11-quality-gates.i18n.yaml | 2 +- .../process/2026-06-11-quality-gates.zh.md | 24 ++-- .../2026-06-11-tsdown-over-dumble.i18n.yaml | 2 +- .../2026-06-11-tsdown-over-dumble.zh.md | 26 ++-- ...26-06-11-vendor-cordis-as-source.i18n.yaml | 2 +- .../2026-06-11-vendor-cordis-as-source.zh.md | 24 ++-- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 2 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 40 +++--- .../2026-06-17-ts-build-config.i18n.yaml | 2 +- .../process/2026-06-17-ts-build-config.zh.md | 65 +++++----- ...6-06-18-markdown-cross-link-lint.i18n.yaml | 2 +- .../2026-06-18-markdown-cross-link-lint.zh.md | 28 ++--- ...-20-core-data-structures-catalog.i18n.yaml | 2 +- ...6-06-20-core-data-structures-catalog.zh.md | 48 ++++---- ...6-06-20-generated-cordis-catalog.i18n.yaml | 2 +- .../2026-06-20-generated-cordis-catalog.zh.md | 36 +++--- .../2026-06-20-rfc-classification.i18n.yaml | 2 +- .../2026-06-20-rfc-classification.zh.md | 42 +++---- .../2026-07-02-tool-schema-catalog.i18n.yaml | 2 +- .../2026-07-02-tool-schema-catalog.zh.md | 46 +++---- ...-07-03-documentation-graph-atlas.i18n.yaml | 2 +- ...2026-07-03-documentation-graph-atlas.zh.md | 70 +++++------ ...4-cordis-jsdoc-completeness-gate.i18n.yaml | 2 +- ...07-04-cordis-jsdoc-completeness-gate.zh.md | 40 +++--- ...2026-07-04-doc-tiers-and-budgets.i18n.yaml | 2 +- .../2026-07-04-doc-tiers-and-budgets.zh.md | 26 ++-- ...-07-04-generate-rfc-index-tables.i18n.yaml | 2 +- ...2026-07-04-generate-rfc-index-tables.zh.md | 22 ++-- ...26-07-04-persistence-log-catalog.i18n.yaml | 2 +- .../2026-07-04-persistence-log-catalog.zh.md | 26 ++-- .../2026-07-05-uniform-rfc-format.i18n.yaml | 2 +- .../2026-07-05-uniform-rfc-format.zh.md | 30 ++--- ...-07-06-export-surface-jsdoc-gate.i18n.yaml | 2 +- ...2026-07-06-export-surface-jsdoc-gate.zh.md | 48 ++++---- ...6-07-06-generated-config-catalog.i18n.yaml | 2 +- .../2026-07-06-generated-config-catalog.zh.md | 34 +++--- .../2026-07-06-node-engine-floor.i18n.yaml | 2 +- .../2026-07-06-node-engine-floor.zh.md | 36 +++--- ...6-07-06-parallel-github-ci-gates.i18n.yaml | 2 +- .../2026-07-06-parallel-github-ci-gates.zh.md | 34 +++--- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 2 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 36 +++--- ...10-readme-known-limitations-gate.i18n.yaml | 2 +- ...-07-10-readme-known-limitations-gate.zh.md | 26 ++-- ...ackage-model-experience-contract.i18n.yaml | 2 +- ...12-package-model-experience-contract.zh.md | 32 ++--- ...-19-drop-mutable-session-summary.i18n.yaml | 2 +- ...6-06-19-drop-mutable-session-summary.zh.md | 22 ++-- ...llapse-trace-only-session-events.i18n.yaml | 2 +- ...0-collapse-trace-only-session-events.zh.md | 28 ++--- ...onsumed-llm-adapter-change-event.i18n.yaml | 2 +- ...-unconsumed-llm-adapter-change-event.zh.md | 22 ++-- ...nconsumed-llm-assembled-surfaces.i18n.yaml | 2 +- ...op-unconsumed-llm-assembled-surfaces.zh.md | 26 ++-- ...26-06-20-prune-dead-seam-methods.i18n.yaml | 2 +- .../2026-06-20-prune-dead-seam-methods.zh.md | 24 ++-- ...-06-20-public-agent-stop-surface.i18n.yaml | 2 +- ...2026-06-20-public-agent-stop-surface.zh.md | 24 ++-- ...ove-agent-boundary-mirror-events.i18n.yaml | 2 +- ...-remove-agent-boundary-mirror-events.zh.md | 22 ++-- .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 2 +- .../2026-06-26-fsspec-style-fs-seam.zh.md | 78 ++++++------ ...07-02-remove-stream-chunk-mirror.i18n.yaml | 2 +- ...026-07-02-remove-stream-chunk-mirror.zh.md | 22 ++-- ...6-07-04-drop-image-content-block.i18n.yaml | 2 +- .../2026-07-04-drop-image-content-block.zh.md | 16 +-- ...6-07-04-drop-inert-request-knobs.i18n.yaml | 2 +- .../2026-07-04-drop-inert-request-knobs.zh.md | 22 ++-- ...consumed-web-observation-surface.i18n.yaml | 2 +- ...p-unconsumed-web-observation-surface.zh.md | 20 +-- .../2026-07-04-fold-stdio-ui-helper.i18n.yaml | 2 +- .../2026-07-04-fold-stdio-ui-helper.zh.md | 18 +-- ...producerless-vocabulary-variants.i18n.yaml | 2 +- ...une-producerless-vocabulary-variants.zh.md | 24 ++-- ...7-04-prune-write-only-fs-surface.i18n.yaml | 2 +- ...26-07-04-prune-write-only-fs-surface.zh.md | 24 ++-- ...-04-remove-agent-steering-mirror.i18n.yaml | 2 +- ...6-07-04-remove-agent-steering-mirror.zh.md | 18 +-- ...26-07-04-share-app-bin-boot-glue.i18n.yaml | 2 +- .../2026-07-04-share-app-bin-boot-glue.zh.md | 22 ++-- ...4-tighten-hook-protocol-contract.i18n.yaml | 2 +- ...07-04-tighten-hook-protocol-contract.zh.md | 20 +-- ...m-acp-bridge-unreachable-surface.i18n.yaml | 2 +- ...-trim-acp-bridge-unreachable-surface.zh.md | 18 +-- ...unconsumed-skill-provider-events.i18n.yaml | 2 +- ...rop-unconsumed-skill-provider-events.zh.md | 20 +-- ...-12-prune-unused-web-seam-fields.i18n.yaml | 2 +- ...6-07-12-prune-unused-web-seam-fields.zh.md | 18 +-- ...026-06-11-property-based-testing.i18n.yaml | 2 +- .../2026-06-11-property-based-testing.zh.md | 22 ++-- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 2 +- .../2026-06-19-acp-snapshot-tests.zh.md | 42 +++---- .../2026-06-19-real-api-e2e-ci.i18n.yaml | 2 +- .../testing/2026-06-19-real-api-e2e-ci.zh.md | 82 ++++++------- ...e-redundant-snapshot-log-goldens.i18n.yaml | 2 +- ...emove-redundant-snapshot-log-goldens.zh.md | 22 ++-- ...-fork-child-replay-seed-boundary.i18n.yaml | 2 +- ...6-22-fork-child-replay-seed-boundary.zh.md | 36 +++--- ...26-06-22-fork-snapshot-scenarios.i18n.yaml | 2 +- .../2026-06-22-fork-snapshot-scenarios.zh.md | 24 ++-- ...6-06-22-subagent-snapshot-replay.i18n.yaml | 2 +- .../2026-06-22-subagent-snapshot-replay.zh.md | 46 +++---- .../2026-07-04-hook-snapshot-matrix.i18n.yaml | 2 +- .../2026-07-04-hook-snapshot-matrix.zh.md | 38 +++--- ...-single-source-acp-replay-config.i18n.yaml | 2 +- ...7-04-single-source-acp-replay-config.zh.md | 22 ++-- ...t-header-content-in-one-scenario.i18n.yaml | 2 +- ...quest-header-content-in-one-scenario.zh.md | 28 ++--- ...7-08-shared-acp-snapshot-package.i18n.yaml | 2 +- ...26-07-08-shared-acp-snapshot-package.zh.md | 34 +++--- .../2026-06-16-typed-event-schemas.i18n.yaml | 2 +- .../2026-06-16-typed-event-schemas.zh.md | 72 +++++------ ...eneric-long-running-tool-runtime.i18n.yaml | 2 +- ...20-generic-long-running-tool-runtime.zh.md | 34 +++--- ...026-06-30-pre-tool-input-rewrite.i18n.yaml | 2 +- .../2026-06-30-pre-tool-input-rewrite.zh.md | 46 +++---- ...code-and-codex-subagent-backends.i18n.yaml | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 68 +++++------ ...-07-08-interactive-side-sessions.i18n.yaml | 2 +- ...2026-07-08-interactive-side-sessions.zh.md | 38 +++--- ...10-sqlite-session-query-provider.i18n.yaml | 2 +- ...-07-10-sqlite-session-query-provider.zh.md | 38 +++--- ...flow-progress-through-tool-calls.i18n.yaml | 2 +- ...workflow-progress-through-tool-calls.zh.md | 28 ++--- ...2026-06-11-api-extractor-reports.i18n.yaml | 2 +- .../2026-06-11-api-extractor-reports.zh.md | 14 +-- ...-06-11-architectural-conformance.i18n.yaml | 2 +- ...2026-06-11-architectural-conformance.zh.md | 16 +-- ...11-supply-chain-and-vendor-drift.i18n.yaml | 2 +- ...-06-11-supply-chain-and-vendor-drift.zh.md | 26 ++-- ...06-20-discover-package-inventory.i18n.yaml | 2 +- ...026-06-20-discover-package-inventory.zh.md | 26 ++-- ...06-20-unify-agent-and-session-id.i18n.yaml | 2 +- ...026-06-20-unify-agent-and-session-id.zh.md | 28 ++--- ...04-prune-dead-core-spine-surface.i18n.yaml | 2 +- ...-07-04-prune-dead-core-spine-surface.zh.md | 70 +++++------ ...plify-session-log-representation.i18n.yaml | 2 +- ...-simplify-session-log-representation.zh.md | 24 ++-- ...deterministic-and-stress-testing.i18n.yaml | 2 +- ...-11-deterministic-and-stress-testing.zh.md | 18 +-- .../2026-06-11-mutation-testing.i18n.yaml | 2 +- .../testing/2026-06-11-mutation-testing.zh.md | 22 ++-- ...-06-11-immutable-public-surfaces.i18n.yaml | 2 +- ...2026-06-11-immutable-public-surfaces.zh.md | 18 +-- ...-06-20-providerless-example-base.i18n.yaml | 2 +- ...2026-06-20-providerless-example-base.zh.md | 18 +-- ...ssembled-assistant-messages-only.i18n.yaml | 2 +- ...20-assembled-assistant-messages-only.zh.md | 24 ++-- ...2026-06-20-drop-acp-session-load.i18n.yaml | 2 +- .../2026-06-20-drop-acp-session-load.zh.md | 24 ++-- ...026-06-20-drop-acp-terminal-meta.i18n.yaml | 2 +- .../2026-06-20-drop-acp-terminal-meta.zh.md | 20 +-- ...-20-drop-bash-output-spill-files.i18n.yaml | 2 +- ...6-06-20-drop-bash-output-spill-files.zh.md | 16 +-- ...-20-drop-durable-step-boundaries.i18n.yaml | 2 +- ...6-06-20-drop-durable-step-boundaries.zh.md | 20 +-- ...6-20-drop-unused-session-lineage.i18n.yaml | 2 +- ...26-06-20-drop-unused-session-lineage.zh.md | 16 +-- ...ld-session-persistence-interface.i18n.yaml | 2 +- ...0-fold-session-persistence-interface.zh.md | 14 +-- ...026-06-20-generic-tool-rendering.i18n.yaml | 2 +- .../2026-06-20-generic-tool-rendering.zh.md | 18 +-- ...6-06-20-retire-mid-turn-steering.i18n.yaml | 2 +- .../2026-06-20-retire-mid-turn-steering.zh.md | 28 ++--- ...-06-20-single-session-acp-bridge.i18n.yaml | 2 +- ...2026-06-20-single-session-acp-bridge.zh.md | 14 +-- ...06-20-truncate-interrupted-turns.i18n.yaml | 2 +- ...026-06-20-truncate-interrupted-turns.zh.md | 26 ++-- ...nimplemented-subagent-vocabulary.i18n.yaml | 2 +- ...ne-unimplemented-subagent-vocabulary.zh.md | 30 ++--- ...apse-workflow-to-foreground-core.i18n.yaml | 2 +- ...collapse-workflow-to-foreground-core.zh.md | 28 ++--- ...ne-unused-skill-registry-surface.i18n.yaml | 2 +- ...-prune-unused-skill-registry-surface.zh.md | 22 ++-- 292 files changed, 2819 insertions(+), 2820 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 67537fda94..7ddbc75586 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-content-block-vocabulary.md: 9414bda624fa6e5fc7e9b11b7a738d32b269af6b -2026-06-11-content-block-vocabulary.zh.md: 32308a5fe58f3a2e3c402982b7067811b9698218 +2026-06-11-content-block-vocabulary.zh.md: 764791cbef65c2031b8337af4f4cb6835e9d312a diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index 32308a5fe5..764791cbef 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -1,4 +1,4 @@ -# RFC:由 dsh-llm 持有的提供方无关内容块词汇 +# RFC:由 dsh-llm 拥有的提供方无关内容块词汇 Status: implemented @@ -10,19 +10,19 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 ## 决策 -自行持有词汇:消息是类型化内容块(`text`、`reasoning`、`tool-call`、`tool-result`)的数组,其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一套可合并扩展映射模式也用于所有「字符串化」字段的类型定义(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出是原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为各提供方的协议格式(wire format):映射成本留在适配器中,这正是它该待的地方。 +自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。 -会话内上下文注入(`context/message`、`steering/message`)渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新 role,因此适配器零负担。真实适配器验证已确认该渲染方式在当前 DeepSeek 行为下有效;如果未来某个提供方出现不匹配,应在该适配器内处理,而非引入新的规范 role。 +会话内上下文注入(`context/message`、`steering/message`)渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 ## 曾考虑的替代方案 -- **镜像 DeepSeek/OpenAI chat-completions 的结构**:对第一个提供方零映射成本,但对富内容(推理(reasoning)、作为结构化块的工具结果)处理起来别扭。 -- **原样采用 Anthropic Messages 的块结构**:经过实战检验,但规范类型将镜像一个 harness 并非首要对接的第三方 API。 +- **镜像 DeepSeek/OpenAI chat-completions 结构**:对第一个提供方零映射成本,但对富内容(推理、结构化块形式的工具结果)处理不便。 +- **原样采用 Anthropic Messages 块结构**:经过实战检验,但规范类型将镜像一个 harness 并非首要对接的第三方 API。 ## 后果 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 -- 多模态块只有在适配器、UI 与上下文压缩(context compaction)三方协同支持时才会回归;见[移除 image 内容块 RFC](../simplification/2026-07-04-drop-image-content-block.md)。 -- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的变体](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[惰性请求旋钮](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。 -- 每个适配器都要承担翻译成本;首批真实适配器已验证了流式输出协议,后续新适配器应继续在适配器本地测试中证明其提供方特有的映射。 -- 跨包边界的 ID 使用品牌类型(`CallId`、`SessionId`、`AgentId`):零运行时成本的名义类型。 +- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md)。 +- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。 +- 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 +- 跨包(package)边界的 ID 使用品牌类型(`CallId`、`SessionId`、`AgentId`)——零运行时开销的名义类型。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml index 5046fe3a38..6e7b3a2125 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-custom-schema-dsl.md: 4c4d572b15d5e474e00e99fc5e7dd89240251c63 -2026-06-11-custom-schema-dsl.zh.md: 31af594846b6b1bc8ce983b7b9d4ddd956a560ff +2026-06-11-custom-schema-dsl.zh.md: eca0428d46ba3b3a4beac0cf9e8f02a9fa198388 diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md index 31af594846..eca0428d46 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md @@ -1,4 +1,4 @@ -# RFC:使用自定义类型化工具 schema DSL 替代 schemastery +# RFC:使用自定义类型化 tool-schema DSL 替代 schemastery Status: implemented @@ -6,18 +6,18 @@ Status: implemented ## 问题 -工具参数必须以标准 JSON Schema 的形式传递给模型,同时让工具作者在 `execute(args)` 中获得类型推导而无需类型断言。schemastery 已用于插件配置,但工具作者 API 需要的是逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。 +工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。 ## 决策 -在 dsh-tools 中实现一个小型自定义 DSL:`SchemaSpec`(逐属性规格,带 `required: true` 布尔值);类型层面的 `InferArgs<S>` 将规格映射为参数类型(required 键为必选,其余通过 `?` 真正可选);运行时的 `schemaSpecToJsonSchema()` 转换器;以及将它们串联起来的 `defineTool()`。`ToolRegistry.register()` 仍接受原始 JSON Schema 的 `ToolDefinition`——MCP 来源的工具就是这样注册的。 +在 dsh-tools 中实现一个小型自定义 DSL:`SchemaSpec`(逐属性规格,带 `required: true` 布尔值);类型层面的 `InferArgs<S>` 将规格映射为参数类型(required 键为必选,其余通过 `?` 标记为真正可选);运行时的 `schemaSpecToJsonSchema()` 转换器;以及将三者串联的 `defineTool()`。`ToolRegistry.register()` 仍然接受原始 JSON Schema 的 `ToolDefinition`——MCP 来源的工具正是以此方式注册。 ## 曾考虑的替代方案 -**schemastery**(已 vendor、用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。 +**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema **生成**,因此会增加间接层却无法干净地产出协议格式(wire format)。 ## 后果 - 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。 -- DSL 刻意保持小巧(string/number/boolean/object/array、enum、default、嵌套 properties/items)。相对完整 JSON Schema 的缺口(union、format、约束)在真实工具提出需求之前暂不填补。 -- `InferArgs` 映射在一次早期可选性 bug 之后已有类型层面的回归测试。 +- DSL 有意保持小巧(string/number/boolean/object/array、enum、default、嵌套 properties/items)。相对完整 JSON Schema 的缺口(union、format、constraint)在真实工具提出需求之前暂不补齐。 +- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index ef2f73d949..0758761505 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-dev-invariants-over-deep-readonly.md: dc89b5b66d02bde2f4fe2794e04b76ecdeac62ae -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 02beb323df84cd442611f0c52b3c56734d6d0554 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 2c3c9e221b42f4b45216d09e825a41b1be3bcee9 diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 02beb323df..2c3c9e221b 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -1,4 +1,4 @@ -# RFC:源头拥有的会话不可变性与开发模式不变式 +# RFC:源端拥有的会话不可变性与开发模式不变式 Status: implemented @@ -6,55 +6,55 @@ Status: implemented ## 问题 -会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实间关系的检查。如果将二者混为一体放进一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。 +会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实之间关系的检查。如果将二者混为一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。 -会话日志是回放、请求重建、持久化和用户可见历史的持久真源。会话包以外的代码必须能够检视该历史,但不能保留一个可以事后改写它的引用;从调用方接收的输入也不能继续连接到调用方拥有的可变对象上。 +会话日志是回放、请求重建、持久化与用户可见历史的持久真源。会话包(package)外部的代码必须能检视历史,但不能保留一个可在之后改写历史的引用;从调用方接受的输入也不能继续连接到调用方拥有的可变对象。 单个值的不可变性只是契约的一半。一份日志可以包含完全不可变的记录,但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务,无法通过冻结单个对象来建立。 -TypeScript readonly 类型不构成充分的运行时边界。它们在程序运行时消失,一次类型转换即可绕过,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。 +TypeScript readonly 类型不是充分的运行时边界。它们在程序运行时消失,类型转换可以绕过它们,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。 ## 决策 -职责在一个始终开启的存储边界与可选的开发断言之间分离。 +职责在始终启用的存储边界与可选的开发断言之间分离。 ### Session 拥有不可变历史 -`Session` 仅在一次递归遍历完成无损 JSON 快照后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切脱离记录,因此校验和存储不可能从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 +`Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切分离记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 -被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者收到同一条记录,`session.events` 返回一份冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的校验、快照与冻结边界。 +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。 -这一保证属于 `Session` 而非可选的监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 +此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 -### 派生请求保持脱离 +### 派生请求保持分离 -`deriveMessages()` 将已记录的表面事件投影为脱离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。请求组装因此可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。 +`deriveMessages()` 将已记录的表面事件投影为分离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。 ### 不变式插件检查关系 -`dsh-invariants` 是一个纯监听的开发插件。它不冻结记录,没有配置;dispose 仅移除其断言。它检查需要追踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent 状态转换、主体正确的作用域分发,以及 agent loop 构建的请求与从其会话日志前缀重建的请求之间的等价性。 +`dsh-invariants` 是一个纯监听器的开发插件。它不冻结记录,没有配置;dispose(资源释放)仅移除其断言。它检查需要跟踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。 -当插件附加到已有或已播种的会话时,它回放不可变日志以重建追踪状态。这使得在轮次中间进行热重载是安全的,同时不赋予插件对会话存储的所有权。 +当插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。这使得在轮次中途热重载是安全的,同时不赋予插件对会话存储的所有权。 ## 曾考虑的替代方案 ### 全面的 deep-readonly 类型 -[被否决的不可变公开表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公开的日志和消息表面全面应用递归 readonly 类型。这能提供编辑器反馈,但不能提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。 +[被否决的不可变公共表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公共日志和消息表面上应用递归 readonly 类型。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。 ### 仅在开发模式冻结 -仅在安装了不变式插件时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终开启,而更昂贵的关系检查保持为可选的开发支持。 +仅当不变式插件安装时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终启用,而开销更大的关系检查则保持为可选的开发支持。 ### 仅在派生消息时克隆 -脱离 `deriveMessages()` 会保护最常见的请求路径,但 `session.events` 的其他读者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,不是替代品。 +分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。 ## 后果 -- 每条被接受的实时或种子会话事件在任何观察者收到之前,都已从调用方拥有的输入中脱离并深度不可变。 +- 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 - `session.events` 暴露稳定的不可变快照,而非私有的增长数组。 - 请求侧的修改无法通过派生消息触及已存储的历史。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或省略该插件不会削弱日志不可变性。 - `dsh-invariants` 没有 `Config` 表面,因为它没有可调节的行为。 -- 运行时边界在每条被接受的事件上承担一次递归快照与冻结的开销;后续读者和缓存投影复用已拥有的不可变记录。 +- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 00e6dc9181..e7645f5f8d 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-event-sourced-sessions.md: 04ff974826ffbc9052c7eb9f5794bc16557241f9 -2026-06-11-event-sourced-sessions.zh.md: a3fec18445673bc2368333413f9802f9822b9c89 +2026-06-11-event-sourced-sessions.zh.md: 12d6aedcc32b4d93f0dbe010854bcd3a0721703f diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index a3fec18445..12d6aedcc3 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -1,28 +1,28 @@ # RFC:事件溯源的会话与派生消息历史 -Status: implemented - [English](2026-06-11-event-sourced-sessions.md) | 中文 +Status: implemented + ## 问题 -MVP 要求严格的基于事件的 trace、logging 系统,session 完全可回放。 +MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严格的基于事件的 trace、logging 系统,session 完全可回放)。 ## 决策 -`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志,是唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`);原始流分片被记入日志以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威来源。回放/fork = 用已有日志初始化一个新会话。 +`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志,是唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`);原始流分片被记录以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威依据。回放/fork = 用已有日志初始化一个新会话。 -追加操作是同步的(热路径从不阻塞在 I/O 上);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。 +追加操作是同步的(热路径从不阻塞于 I/O);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。 顺序契约:agent loop(智能体循环)先追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。 ## 曾考虑的替代方案 -**可变消息数组 + 事件作为通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身就是状态,分歧在结构上不可能发生。 +**可变消息数组 + 事件仅作通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身即是状态,分歧在结构上不可能发生。 ## 后果 -- 回放、trace 与遥测在结构上得到保证,而非事后附加。 -- 持久化仍是插件关注点;内存存储随 dsh-session 一起发布。 +- 回放、追踪与遥测在结构上得到保证,而非事后附加。 +- 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。 - 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志变为持久后冻结了其形状。 -- 派生成本随日志长度增长——压缩(未来插件)是预期的缓解手段,而非日志变更。 +- 派生成本随日志长度增长,压缩(未来插件)是预期的缓解手段,而非日志变更。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml index 00320a6991..8634a86f36 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-microkernel-event-taxonomy.md: c66968257a5a6304f187ccb5b9a162aa143e608d -2026-06-11-microkernel-event-taxonomy.zh.md: 86750f6ece488e735f2c14f759db7c9eef360828 +2026-06-11-microkernel-event-taxonomy.zh.md: 7becf5872aee16fe21b4acbbc61920ed91c40f44 diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md index 86750f6ece..7becf5872a 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -6,26 +6,26 @@ Status: implemented ## 问题 -产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩(context compaction)、沙箱、权限、UI、持久化、MCP、skill(技能)都必须能以插件形式编写,而无需修改核心。 +产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩(context compaction)、沙箱、权限、UI、持久化、MCP、skill(技能)都必须能以插件形式编写,无需修改核心。 ## 决策 -纯 Cordis 事件分类体系(taxonomy)。循环的扩展 seam 是带有明确分发模式的类型化事件: +纯 Cordis 事件分类体系。agent loop(智能体循环)的扩展 seam 是带类型的事件,具有明确的分发模式: -- **waterfall(瀑布式事件)**(around-middleware):插件可以变换、否决或包装:`agent/prompt-submit`、`agent/request`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 -- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时都会运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 -- **parallel**(await 扇出):每个监听器都必须获得独立执行机会:`session/flush` 持久性检查点。 -- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流式分片、生命周期、错误,以及包含不可变 `tools/result` 观测值的事件。 +- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决或包装:`agent/prompt-submit`、`agent/request`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 +- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 +- **parallel**(await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。 +- **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。 -事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且本身可替换——它之外的任何代码都不得依赖它。 +事件词汇定义在接口包中(dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且自身可替换——外部不得依赖它。 ## 曾考虑的替代方案 -**专用中间件栈(koa-compose 风格)** 与 **插件插入其中的显式阶段状态机**:两者都需要重新实现分发、dispose(资源释放)和重载语义,而 Cordis 原生事件系统已经提供了这些;作为 Cordis effect,监听器天然获得 HMR(热模块替换)和 dispose 能力。 +**专用中间件栈(koa-compose 风格)** 与**显式阶段状态机(插件向其中插入阶段)**:两者都需要重新实现 Cordis 原生事件系统已提供的分发、dispose(资源释放)与重载语义;作为 Cordis effect,监听器天然获得 HMR(热模块替换)与 dispose 能力。 ## 后果 -- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持最新)。 -- HMR 和 dispose 免费获得:监听器和注册都是 Cordis effect。 -- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——已在 AGENTS.md 中记录,并由组合测试覆盖。 -- 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何 seam 的 steering(中途引导)绝不会被搁置(有回归测试保障)。 +- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。 +- HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。 +- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。 +- 循环必须具备防御性:插件异常在轮次级别被隔离,任何 seam 发出的 steering(中途引导)永远不会被搁置(有回归测试保障)。 diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index 8e7b36b58d..9ae6b5d602 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-runtime-arg-validation.md: 6da117643166d304bee1d368a314cc1602cac828 -2026-06-11-runtime-arg-validation.zh.md: fb67568da3bfc7f1f8fcc0429b6c68193e04693a +2026-06-11-runtime-arg-validation.zh.md: 5f41ff99a2d56f39ff8d61191d92dc782f163c82 diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index fb67568da3..5f41ff99a2 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -6,19 +6,19 @@ Status: implemented ## 问题 -`defineTool`([自定义 schema DSL](2026-06-11-custom-schema-dsl.md))通过 `InferArgs<S>` 映射为工具作者提供了类型化的 `execute(args)`。但该类型只是编译期对一个运行时值的声明:这个值以模型生成的 JSON 形式到达,没有任何机制强制模型遵守 schema。因此,一次格式错误的调用(缺少必填键、声明为数字的位置传入字符串、枚举值超出集合)会以「仅有类型之名」的状态抵达 `execute`。工具体要么在错误形状上崩溃(产生一条模型无法据以行动的通用堆栈跟踪),要么更糟:静默地行为异常。与此同时,转换器已经编码了校验器遍历所需的完整结构。 +`defineTool`([自定义 schema DSL](2026-06-11-custom-schema-dsl.md))通过 `InferArgs<S>` 映射为工具作者提供了类型化的 `execute(args)`。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串、枚举值超出集合)会以「仅名义类型化」的状态到达 `execute`。工具函数体要么在错误形状上崩溃(产生模型无法据以自我修正的通用堆栈跟踪),要么更糟——静默地行为异常。与此同时,转换器已经编码了校验器遍历所需的完整结构。 ## 决策 -`validateArgs(spec, args): string[]` 对一个运行时值解释 `SchemaSpec`,返回人类可读的违规列表(空 = 合法),且是全函数(从不抛出异常)。`defineTool` 在调用类型化的工具体之前运行它;如果存在违规,则抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`,消息列出违规项),注册表既有的 execute-waterfall catch 将其转为模型可读取并据以自我修正的 `isError` 结果。 +`validateArgs(spec, args): string[]` 对运行时值解释一个 `SchemaSpec`,返回可读的违规列表(空数组 = 合法),且是全函数(永不抛出异常)。`defineTool` 在调用类型化函数体之前运行它;存在违规时抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`,消息中列出违规项),注册表既有的 execute-waterfall(瀑布式事件)catch 将其转为模型可读取并据以自我修正的 `isError` 结果。 -校验器严格镜像 `schemaSpecToJsonSchema` 的语义:遍历相同的结构、执行相同的规则:顶层必须是非数组对象;必填键仅来自 `required: true`;允许额外键(不设 `additionalProperties: false`);不应用 `default`;没有 `properties`/`items` 的 `object`/`array` 属性仅做类型检查;`enum` 是成员判定。原始注册的(MCP)工具不受影响:它们自行校验输入。 +校验器严格镜像 `schemaSpecToJsonSchema` 的语义——遍历相同结构、执行相同规则:顶层必须是非数组对象;必需键仅来自 `required: true`;允许额外键(不设 `additionalProperties: false`);不应用 `default`;没有 `properties`/`items` 的 `object`/`array` 属性仅做类型检查;`enum` 是成员资格检查。原始注册的(MCP)工具不受影响——它们自行校验输入。 ## 后果 -- 模型在自身格式错误的调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。 -- 校验器与 `InferArgs` 必须保持一致;一组[属性测试](../testing/2026-06-11-property-based-testing.md)会生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。 -- `ToolArgsError` 目前是一个带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,而不影响读取 `.message` 的调用方。 +- 模型在自身畸形调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。 +- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。 +- `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,但不影响读取 `.message` 的调用方。 - 校验开销相对于一次模型调用可忽略不计。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml index 84528dccf1..aa6cdb26ff 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-structured-error-taxonomy.md: 2baf88a1f942215e79561e565f455276c80178c4 -2026-06-11-structured-error-taxonomy.zh.md: f4762c4e92fb94c5bdbccc5f9a61e1496dc4c66d +2026-06-11-structured-error-taxonomy.zh.md: 90b0fdd7f6c8b4f0565c6538c2ddd4cb31680c38 diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md index f4762c4e92..90b0fdd7f6 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md @@ -6,21 +6,21 @@ Status: implemented ## 问题 -错误跨越服务边界时只是裸字符串。工具错误被扁平化为一个文本块——name、code 和 stack 全部丢失——导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES,模型得到的反馈也不如本可以获得的那样可操作。非 Error 的 throw 退化得更严重:agent loop(智能体循环)将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对一个通用基类做 `instanceof`。 +故障跨越 seam 时只是裸字符串。工具错误被扁平化为一个文本块(name、code 和 stack 全部丢失),导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES,模型得到的反馈也不如本可以那样具有可操作性。非 Error 的 throw 退化更严重:agent loop(智能体循环)将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对其进行通用的 `instanceof` 判断。 ## 决策 -在 `dsh-llm`(叶子包(package),所有其他包都已依赖它——不引入新的依赖边)中建立一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 的 `cause` 链式传递、`name` 默认为子类名。`isHarnessError` 在服务边界处做类型收窄。 +在 `dsh-llm`(叶子包,所有其他包都已依赖它,不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause` 链接、`name` 默认为子类名。`isHarnessError` 在 seam 处做类型收窄。 - `LlmError`、`ToolArgsError`(dsh-tools)和 `InvariantError`(dsh-invariants)现在继承该基类,保留各自既有的 code。 -- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存入日志,供重试/沙箱插件和回放使用。面向模型的文本块不变。 -- agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError`(`code: 'UNKNOWN'`,原始值通过 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件已暴露 `code`)。 +- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存活到日志中,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。 +- agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError`(`code: 'UNKNOWN'`,原始值作为 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件此前已暴露 `code`)。 ## 后果 -- 错误在端到端链路上可被机器路由:插件可以按 `error.code` 分支,而非对 message 做子串匹配。 -- 一个基类被广泛导入,但它位于所有包本已依赖的包中,代价只是一条 import 语句,而非一条新的依赖边。 -- `deriveMessages` 不会将 `error` 字段呈现到模型历史中——模型仍然看到文本块;结构化字段服务于代码逻辑和回放。 -- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类添加了跨服务边界的路由元数据,不改变面向模型的文本。 +- 错误端到端可机器路由:插件可以基于 `error.code` 分支,而无需对 message 做子串匹配。 +- 一个基类被广泛导入,但它位于所有包已经依赖的包中,代价仅是一条 import 语句,而非新的依赖边。 +- `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。 +- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml index c0625866a3..5f5b2ef266 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-tool-schemas-in-prompt-assembly.md: 443e6f20115e5a76001b4466c2d756675adbd886 -2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 2d23d6fd0ead17fbeed3feaf3cec864813ef47e4 +2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 03624b98fabdedf691f3fb448cc5f37ba5a649ef diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md index 2d23d6fd0e..03624b98fa 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md @@ -1,4 +1,4 @@ -# RFC:工具 schema 属于系统提示词组装的一部分 +# RFC:工具 schema 是系统提示词组装的一部分 Status: implemented @@ -6,18 +6,18 @@ Status: implemented ## 问题 -在协议格式(wire format)层面,工具 schema 通过模型请求中专用的 `tools` 字段传输,而非嵌入提示词文本。但从架构角度看,「模型被告知它能做什么」是一个内聚的关注点:提示词段落和工具列表由同一批插件贡献组装而成,并在同一时刻被消费。 +在协议格式(wire format)层面,工具 schema 通过模型请求中专用的 `tools` 字段传输,而非嵌入提示词文本。然而从架构角度看,「模型被告知它能做什么」是一个统一的关注点:提示词段落与工具列表由相同的插件贡献组装,并在同一时刻被消费。 ## 决策 -`PromptAssembly { sections, tools }`:系统提示词服务同时收集有序的文本段落和工具 schema(工具注册表自动贡献一个提供方)。agent loop(智能体循环)每步消费一个 assembly;适配器将 `sections` 映射到提供方的 system 槽位,将 `tools` 映射到协议格式的 `tools` 字段。因此 `system-prompt/assemble` waterfall(瀑布式事件)是模型前置信息的唯一拦截点——工具过滤(ToolSearch / 渐进式披露)是一次 assembly 改写,与提示词编辑无异。 +`PromptAssembly { sections, tools }`:系统提示词服务同时收集有序的文本段落和工具 schema(工具注册表自动贡献一个提供方)。agent loop(智能体循环)每个步骤消费一份 assembly;适配器将 `sections` 映射到提供方的 system 槽位,将 `tools` 映射到协议格式的 `tools` 字段。因此 `system-prompt/assemble` waterfall(瀑布式事件)是模型预先获知的所有信息的唯一拦截点:工具过滤(ToolSearch / 渐进式披露)是一次 assembly 重写,与提示词编辑无异。 ## 曾考虑的替代方案 -**循环分别向工具注册表和提示词服务查询**——将一个内聚的关注点拆到两个 seam 上;任何想塑造「模型被告知什么」的拦截(工具过滤、plan 模式)都需要在两个接口上各挂一个监听器,而非一次 assembly 改写。 +**循环从工具注册表和提示词服务分别查询**:将一个统一的关注点拆到两个 seam 上;任何想影响「模型被告知什么」的拦截(工具过滤、plan 模式)都需要在两个接口上各挂一个监听器,而非一次 assembly 重写即可完成。 ## 后果 - 一条 waterfall 统管模型的常驻上下文;plan 模式等插件可以在一个监听器中同时替换提示词文本和可见工具。 -- assembly 接口通过声明合并实现可扩展(无需无类型的 `extras` 包——扩展即声明合并)。 -- 「schema 出现在提示词服务中」有轻微的概念意外感,本文与 package README 对此做了说明。 +- assembly 接口通过声明合并实现可扩展(没有无类型的 `extras` 包——扩展即声明合并),为未来的槽位预留空间。 +- 将 schema 放在「提示词」服务中略有概念上的意外感,已在本文及 package README 中加以说明。 diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index 25e7f01d0f..d0eac81287 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-13-capability-seams.md: e9d417dbd2bafcaece39601b12dbb310feb1e19b -2026-06-13-capability-seams.zh.md: e5d9be803f71ef3b85f79ed483dab64612a27fc7 +2026-06-13-capability-seams.zh.md: c569f3df083ec48bd05e6be2d3a1e5875fde362f diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md index e5d9be803f..c569f3df08 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -1,32 +1,32 @@ -# RFC:能力 seam——接口/实现/消费方拆分 - -Status: implemented +# RFC:能力 seam——接口/实现/消费方三分 [English](2026-06-13-capability-seams.md) | 中文 +Status: implemented + ## 问题 -harness 具有可替换的能力:目前是 bash 执行,未来会有沙箱/远程执行器和替代模型提供方。一项能力有三个关注点,它们以不同的速率、出于不同的原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面对什么来编程)。将三者打包在一个 package 中会耦合这些变化速率:把本地执行器换成沙箱执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 +harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面向什么编程)。将三者捆绑在一个包(package)中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 -这与「运行时谁提供、谁需要一项能力」是不同的问题,后者 Cordis 已经用 service + `inject` 回答了(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到该服务存在)。那套机制是必要的,但它不决定 package 边界;本 RFC 决定。 +这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过 service + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 RFC 决定的是包的边界。 ## 决策 -一项可替换的能力拆为**三个 package**: +一项可替换的能力由**三个包**构成: -1. **接口**:一个抽象 service 加词汇类型,拥有 `ctx.<key>`,仅依赖 cordis(例如 `dsh-bash`:`BashExecutor`、`BashRunResult`、`BashTask`)。 -2. **实现**:一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、spill-file 截断)。沙箱/远程后端是实现同一接口的兄弟 package。 -3. **消费方**:模型和插件看到的东西(例如 `dsh-tool-bash`:`bash`/`bash_output`/`bash_kill` 工具 schema)。消费方 `inject` 接口 key,从不导入实现类型。 +1. **接口**——一个抽象服务加词汇类型,拥有 `ctx.<key>`,仅依赖 cordis(例如 `dsh-bash`:`BashExecutor`、`BashRunResult`、`BashTask`)。 +2. **实现**——一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、溢出文件截断)。沙箱化/远程后端是实现同一接口的兄弟包。 +3. **消费方**——模型和插件看到的内容(例如 `dsh-tool-bash`:`bash`/`bash_output`/`bash_kill` 工具 schema)。消费方 `inject` 接口键,从不导入实现类型。 -实现与消费方随后独立演进:沙箱执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。 +实现与消费方由此独立演进:沙箱化执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。 -当各部分确实属于同一关注点时,拆分并非强制:LLM seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 表面),适配器作为实现 package。不要预防性拆分:只有一种可设想的实现和一个消费方的能力保持为一个 package,直到第二个出现。 +当各部分确实属于同一个关注点时,三分并非强制:LLM(大语言模型) seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop(智能体循环)本身,而非可替换的 schema 表面),适配器作为实现包。不要预防性地拆分——如果一项能力只有一种可设想的实现和一个消费方,就保持为一个包,直到第二种出现。 ## 曾考虑的替代方案 -- **合并为一个 package**:否决,因为它重新耦合了拆分所要分离的三种变化速率(这正是拆分的全部意义)。 -- **`@cordisjs/plugin-capability`**:完全不同的维度。它是一个权限/能力*安全*服务(带继承的命名权限,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,而**不是**替换实现的机制。混淆这两个「能力」正是本 RFC 所指出的陷阱。 +- **单一合并包**:否决。因为它重新耦合了三分设计本要分离的三种变化速率(这正是拆分的意义所在)。 +- **`@cordisjs/plugin-capability`**:这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,**不是**替换实现的机制。混淆这两个「能力」概念正是本 RFC 所指出的陷阱。 ## 后果 -每项能力多出更多 package 和更多样板代码(一套 `package.json`/`tsconfig`/README,加上 inject 接线)。换来的是:实现与消费方独立发布和版本化,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions("Capability seams are three packages")和 [architecture.md](../../../architecture.md) § "Capability seams" 中;bash 三件套是参考模板。何时合并、何时拆分是一个判断性决策,架构文档已做说明——本 RFC 记录的是*为什么*默认选择拆分。 +每项能力需要更多包和更多样板代码(一组 `package.json`/`tsconfig`/README,加上 inject 接线)。换来的是:实现与消费方独立发布和版本管理,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions("Capability seams are three packages")和 [architecture.md](../../../architecture.md) § "Capability seams" 中;bash 三件套是参考模板。何时合并、何时拆分是一个判断问题,架构文档对此有详细说明——本 RFC 记录的是*为什么*默认选择拆分。 diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index a46193a7cd..df04a14d6a 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-13-twin-llm-adapters.md: 4efefcf4e2f6b1d60567ba3bfed7ae1ea53a7a4e -2026-06-13-twin-llm-adapters.zh.md: 806eea84ff3e6c4de988348964429ccaea27f4ba +2026-06-13-twin-llm-adapters.zh.md: 6cabd95c5361afdea5b33ffdee34a5acb6026f7f diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 806eea84ff..6cabd95c53 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -1,4 +1,4 @@ -# RFC:以两个 LLM 适配器作为设计验证孪生 +# RFC:以两个 LLM 适配器作为设计验证孪生体 Status: implemented @@ -6,22 +6,22 @@ Status: implemented ## 问题 -`dsh-llm` 拥有一套提供方无关的流式输出词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇只针对单一适配器定义,就有把该适配器的怪癖烘焙进「中立」契约的风险:那个唯一实现碰巧做了什么,就会变成事实上的规范;而抽象在第二个提供方到来之前都无法被验证——届时泄漏已经代价高昂。 +`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为烘焙进「中立」契约:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时泄漏已代价高昂。 ## 决策 -从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现: +从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建: -- `dsh-llm-deepseek`:手写 `fetch` + SSE 解析,直连 DeepSeek API。 -- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库(有自己的事件词汇)访问同一端点。 +- `dsh-llm-deepseek`:手写 `fetch` + SSE(Server-Sent Events)解析,直接对接 DeepSeek API。 +- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 -它们强制执行的规则是:**凡是 StreamChunk 词汇无法同时为两个实现表达的东西,都是核心词汇的 bug**——立即暴露,而非等到下一个提供方才发现。这对孪生确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程为原始 JSON 字符串,以及消费方必须在两侧都处理的两条合法错误路径(从 `stream()` 抛异常,*或*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由库封装的适配器暴露出来的,单一手写适配器会将其掩盖。 +二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏。 ## 曾考虑的替代方案 -- **单一适配器**:代码更少、e2e 成本减半,但「提供方无关」的声明无法验证;词汇会默默编码 DeepSeek-via-fetch 的假设。 -- **mock 第二适配器**:更便宜,但不会触及真实提供方的协议格式(wire format)怪癖,因此证明力有限。孪生是真实对真实。 +- **单一适配器**:代码更少、e2e 成本减半,但「提供方无关」的声明无从验证;词汇会默默编码 DeepSeek-via-fetch 的假设。 +- **mock 第二适配器**:更便宜,但不会触及真实提供方的协议格式(wire format)怪癖,因此证明力有限。孪生体是真实对真实的验证。 ## 后果 -孪生使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理模式下的表现——换来的是对 seam 中立性的持续验证和第二份实现示例。两者都使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来的一致性测试套件可以通过一份取代性 RFC 来论证退役其中一个适配器。 +孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 RFC 论证退役其中一个适配器。 diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 3858139410..c6b9eaf967 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-14-session-persistence.md: 4078487b71862791dd25cf2afcfc03255cfeb0be -2026-06-14-session-persistence.zh.md: 801632b56d2056b36dbf7869f5114d74599bd5d5 +2026-06-14-session-persistence.zh.md: a44ef9647ca4003bc81023a54b7e9b5945003b02 diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md index 801632b56d..a44ef9647c 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -1,4 +1,4 @@ -# RFC:会话持久化——基于既有 `SessionEvent` 的抽象服务 +# RFC:会话持久化作为基于现有 `SessionEvent` 的抽象服务 Status: implemented @@ -6,31 +6,31 @@ Status: implemented ## 问题 -会话此前只存在于内存中。示例插件 `session-jsonl.ts`(在两个 examples 目录中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、dispose 时 fire-and-forget 地排空缓冲区),没有列表功能,也没有格式版本控制。没有任何东西能把磁盘上的历史会话重新注入一个活跃的 agent,因此持久恢复(「继续昨天的任务」)、持久 fork,以及 ACP 的 `session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都不可能实现。 +会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复("继续昨天的任务")、持久 fork 以及 ACP(Agent Client Protocol)的 `session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都无法实现。 -[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM 历史。持久化必须忠于这一点:直接持久化既有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前是文件存储,将来是数据库存储——统一在一个接口之后。 +[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行"持久化消息"类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。 ## 决策 持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-bash` 模板),而非循环或核心逻辑: -1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是既有的 `SessionEvent`(`{ type, seq, time, data }`),逐字复用,无转换类型。 -2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字保留**包括 `assistant/chunk`**)。 +1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 +2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字节保留,**包括 `assistant/chunk`**)。 -以下关键选择记录于此,因为它们是持久的、有争议的、且出人意料的: +以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的: -- **规范持久日志逐字保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载校验 `events[i].seq === i` 要求日志*连续*;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。未来可以将过滤 chunk 的投影作为带独立重编号的派生视图,但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的 `turn/end` 之前的事件永不重写,且循环只在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 会保留其中连续且可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。这些合成结果使恢复后的 provider transcript 保持有效。只有不完整的最后一条记录会被丢弃;如果在最后一个真实 `turn/end` 或之前出现解析错误或序号间隙,则视为损坏,该会话不可加载。 -- **文件后端为规范实现,数据库后端为已验证的可替换方案。** `SessionEvent` 1:1 映射为一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口不变(opencode 在 SQLite/WAL 上运行的正是这个形状),且它通过与 JSONL 后端相同的 `runPersistenceContract` 套件——因此契约以相同的语义(惰性物化、加载时关闭中断轮次、连续 seq)约束两个后端,一次表达在文件字节上,一次表达在行上。 -- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,通过新的只读属性 `session.header` 附加到 `Session`——永远不在 `SessionEventMap` 中,永远不会到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件在 seed/fork 会话时可以免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因为是死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 与 `ctx.agents.resume()` 是异步工厂;resume 还额外跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 继续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop 不会硬注入 `sessionPersistence`(那会让非持久化的演示永远挂起);当 `sessionPersistence` 不存在时,`resume` 以明确的错误拒绝。 +- **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,而过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写到 `turn/end` 的事件永不被重写,且循环仅在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。 +- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当 `sessionPersistence` 不存在时,`resume` 以明确的错误拒绝。 ## 曾考虑的替代方案 -上述每个关键选择在陈述时已记录了其被否决的替代方案:**过滤 chunk 的规范日志**(Codex 的 `policy.rs` 形状)——破坏连续 seq 契约;**截断崩溃的轮次**——静默销毁长时间自主运行的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**——会让非持久化的演示永远挂起。 +上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 chunk 的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布的会话格式固定在 `SESSION_FORMAT_VERSION = 0`,按 AGENTS.md 的预发布立场吸收形状变动)。坦率地说:仅追加 + 刷写对部分尾部写入(加载时容忍)是健壮的,但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是将来更强的选项。 +格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 -新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`、`create(id?, options?)` 签名)。收获:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部建立在既有的事件溯源日志之上,后端可在一个接口后替换。可复用的 `runPersistenceContract` 套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字保留。 +新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。 diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml index 545fdae4ff..72eb9e3d7a 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-15-turn-enclosure-invariant.md: bf0789f21ba7bd928e023bb5fd844d9ec78c4bc1 -2026-06-15-turn-enclosure-invariant.zh.md: 9f6f69b923ed7feff51122c4e4040bff3c9db8ea +2026-06-15-turn-enclosure-invariant.zh.md: 644e7b975e024286d5307f586f509c2b4a9f21ea diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md index 9f6f69b923..644e7b975e 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md @@ -1,4 +1,4 @@ -# RFC:每个会话事件必须包含在一个轮次内 +# RFC:每个会话事件都封闭在一个轮次内 Status: implemented @@ -6,37 +6,37 @@ Status: implemented ## 问题 -持久化的会话持久化后端(在一个配套变更中引入)以**轮次**作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,`load` 会用一个合成的 `turn/end {kind:'interrupted'}` 将其关闭,同时保留该轮次的真实事件(见[会话持久化](2026-06-14-session-persistence.md))。这种恢复只有在没有任何*合法的*持久化事件位于轮次之外(即上一个 `turn/end` 与下一个 `turn/start` 之间的间隙)时才是良定义的,否则这类事件会被裹入下一个轮次的中断关闭中。 +持久化的会话持久化后端(在配套变更中引入)以**轮次**作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,`load` 会用一个合成的 `turn/end {kind:'interrupted'}` 将其关闭,同时保留该轮次的真实事件(见[会话持久化](2026-06-14-session-persistence.md))。这种恢复只有在没有任何*合法的*持久事件位于轮次之外(即上一个 `turn/end` 与下一个 `turn/start` 之间的间隙)时才是良定义的,否则这类事件会被卷入下一个轮次的中断关闭中。 -该假设并不成立。有两条路径在轮次之外记录了事件: +这一假设并不成立。有两条路径在任何轮次之外记录了事件: -1. **排队的用户消息。** agent loop(智能体循环)排空排队消息并在 `turn/start` **之前**追加 `user/message`,导致一个轮次自身的提示词落在前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。 -2. **空闲时的上下文注入。** `agent.inject()` 直接追加一条 `context/message`。它在生产环境中的实际调用方是 `dsh-tool-bash`,后者从 `ctx.bash.onTaskDone` 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,经常发生在 agent **空闲**(两个轮次之间)时。 +1. **排队的用户消息。** agent loop(智能体循环)排空排队消息并在 `turn/start` **之前**追加 `user/message`——于是一个轮次自身的提示词落在了前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。 +2. **空闲时的上下文注入。** `agent.inject()` 直接追加一条 `context/message`。它在生产环境中的真实调用方是 `dsh-tool-bash`,后者从 `ctx.bash.onTaskDone` 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,而这经常发生在 agent **空闲**(轮次之间)时。 -对于情况 2,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end`),`scanLog` 会将其视为崩溃残留并**在恢复时丢弃**——注入的上下文虽然已持久化到磁盘,但在重新加载时被静默丢失。情况 1 单独来看是无害的(`user/message` 之后总是紧跟它触发的轮次),但使得「什么可以出现在轮次之外」这条规则变得模糊。 +在情况 2 中,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end`),`scanLog` 会将其视为崩溃残留并在**恢复时丢弃**——注入的上下文已持久写入磁盘,但重新加载后被静默丢失。情况 1 本身无害(`user/message` 之后总会跟着它触发的轮次),但使「什么可以出现在轮次之外」这条规则变得模糊。 ## 决策 -**每个会话事件都位于一个轮次内部**——在一个 `turn/start` 与其匹配的 `turn/end` 之间。具体而言: +**每个会话事件都位于一个轮次内部**:在 `turn/start` 与其匹配的 `turn/end` 之间。具体而言: -- agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非在其之前。因此,这些消息一经记录,`turn/end` 就已被承诺,而现有的 finalizer 保证了这一点。 -- 在 agent **运行中**调用 `agent.inject()` 时,其 `context/message` 追加到已打开的轮次中(行为不变)。 -- 在 agent **空闲时**调用 `agent.inject()`,系统将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`。 -- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而非维护一个私有计数器,因此空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。 -- `dsh-invariants` 插件在开发模式下**强制执行**该不变式:在没有打开的轮次时追加 `user/message`/`context/message`/`steering/message` 会抛出 `InvariantError`。 +- agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非之前。因此,一旦这些消息被记录,就欠下一个 `turn/end`,既有的 finalizer 保证它被写入。 +- agent **运行中**调用 `agent.inject()` 时,`context/message` 追加到已打开的轮次中(行为不变)。 +- agent **空闲时**调用 `agent.inject()`,则将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`。 +- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。 +- `dsh-invariants` 插件在开发环境中**强制执行**该不变式:在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出 `InvariantError`。 -可序列化性不变式在同一个源码边界强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一处统一管控,而非由下游恰好在监听的某个后端去发现。 +可序列化性不变式在同一源码边界处强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一个位置统一管控,而非由下游碰巧在监听的某个后端各自发现。 ## 曾考虑的替代方案 -**放宽读取端而非约束生产端**——让 `scanLog` 提交位于已打开轮次之外的事件。否决:一条可检查的生产端规则优于一条更宽松的边界扫描逻辑,后者需要同时推理部分轮次*和*轮次间的散落事件。 +**放宽读取端而非约束生产端**——让 `scanLog` 提交位于已打开轮次之外的事件。否决:一条单一、可检查的生产端规则优于一个更宽松的边界扫描(后者需要同时推理部分轮次*和*轮次间的散落事件)。 ## 后果 -轮次现在是*唯一的*持久化/回放边界,因此[会话持久化](2026-06-14-session-persistence.md)的崩溃恢复规则是完备的,而不仅仅是充分的:一个被中断的最终轮次会被关闭(用合成的 `turn/end {interrupted}`),其真实事件被保留,且完全不存在将轮次间上下文混入其中的风险,因为不再有轮次间上下文。`scanLog` 保持简单(至多一个可能未关闭的最终轮次,永远没有散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后得以存活。 +轮次现在是*唯一的*持久性/回放边界,因此[会话持久化](2026-06-14-session-persistence.md)的崩溃恢复规则是完备的,而不仅仅是充分的:被中断的最终轮次被关闭(用合成的 `turn/end {interrupted}`),其真实事件得以保留,且零风险将轮次间上下文混入其中,因为不存在轮次间上下文。`scanLog` 保持简洁(最多一个可能未关闭的最终轮次,绝无散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后依然存活。 -代价:空闲时调用 `agent.inject()` 现在写入三行日志而非一行,且推导出的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——`deriveMessages()` 本就纯粹按事件类型推导,因此渲染结果不变。`injection` 触发器是一个新的磁盘词汇值;与每一个 `SessionEventMap`/`TurnTriggerMap` 的新增项一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(`turn/start` 现在先于 `user/message`),这对任何断言旧顺序的代码是可观测的——agent loop 自身的测试是唯一的此类消费方。 +代价:空闲时调用 `agent.inject()` 现在写入三行日志而非一行;派生的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——`deriveMessages()` 已经纯粹按事件类型派生,因此渲染结果完全相同。`injection` 触发器是一个新的磁盘词汇值;与每次 `SessionEventMap`/`TurnTriggerMap` 的新增一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(`turn/start` 现在先于 `user/message`),这对任何断言旧顺序的代码可观测——agent loop 自身的测试是唯一的此类消费方。 -该规则有意采用生产端强制执行 + 开发模式检查的方式,而非读取端容忍:未来的后端(SQLite/WAL)可以免费继承同样干净的边界,而在轮次外记录事件的插件会在开发模式下大声失败,而非在下次重新加载时静默丢失数据。 +该规则有意采用生产端强制、开发环境检查的方式,而非读取端容忍的方式:未来的后端(SQLite/WAL)无需额外工作即可继承同样干净的边界,而在轮次外记录事件的插件会在开发环境中大声失败,而非在下次重新加载时静默丢失数据。 -轮次内检测到的失败在 `turn/end` 之前记录。之后的 flush 失败没有合法的轮次内位置,因此通过 `agent/error` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。 +轮次内检测到的失败在 `turn/end` 之前记录。后续的 flush 失败没有有效的轮次内位置,因此通过 `agent/error` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。 diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 5d92edb79a..11faf45d26 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-17-filesystem-capability-seam.md: c502ae712de22e97661192057d4410c7c55ea044 -2026-06-17-filesystem-capability-seam.zh.md: 01a4318237ebbd29fe3effa3f8528b6e4a5132d6 +2026-06-17-filesystem-capability-seam.zh.md: 4e544e24c548a52bde254886a65ff2fdb559a986 diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 01a4318237..4e544e24c5 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -1,42 +1,42 @@ # RFC:文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具 -Status: implemented - [English](2026-06-17-filesystem-capability-seam.md) | 中文 +Status: implemented + ## 问题 -harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read`、`write` 和 `edit` 直接使用 `node:fs`,面向模型的工具包将同时拥有文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。 +harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read`、`write` 和 `edit` 直接使用 `node:fs`,面向模型的工具包将同时承担文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。 这把三个独立变化的关注点耦合在了一起: 1. 文件系统契约:插件可以请求哪些操作。 -2. 后端:当前是本地磁盘,未来可能是沙箱/远程/项目范围的文件系统。 +2. 后端:当前是本地磁盘,未来可能是沙箱/远程/项目作用域的文件系统。 3. 消费方接口:面向模型的 `read` / `write` / `edit` schema 与结果格式化。 -没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,也会搅动工具 schema、演示和提示词引导。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。 +如果没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,工具 schema、演示和提示词引导也会被迫变动。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式的后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。 -我们需要让文件系统工具在成为公开包接口之前,以与 bash 相同的能力 seam 形态落地。 +我们需要文件系统工具在成为公开包(package)接口之前,以与 bash 相同的能力 seam 形态落地。 ## 决策 -文件系统访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): +文件系统访问是一个一等的能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): 1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。 2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。 -3. `@deepseek-ai/dsh-tool-fs`(`packages/fs/tool-fs`)通过 `ctx.fs` 提供面向模型的 `read`、`write` 和 `edit` 工具,并作为执行器分发 `fs/*` 事件。 +3. `@deepseek-ai/dsh-tool-fs`(`packages/fs/tool-fs`)通过 `ctx.fs` 提供面向模型的 `read`、`write` 和 `edit` 工具,是分发 `fs/*` 事件的执行器。 消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 -先读后写/编辑与已观察状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门而非 `ctx.fs` 方法贡献;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得先读后写/编辑能力。本 RFC 确立了三包 seam;策略从提供方基类拆出的决策见 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md),其作为事件门插件(而非方法服务)的实现见 [event-gate RFC](2026-06-26-file-context-as-event-gate.md)。本文已更新为描述最终落地的四包形态。 +读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 RFC 确立了由三个包构成的 seam;策略从提供方基类拆出的决策由 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。 -第一个后端刻意仅限本地:`dsh-fs-local` 针对宿主文件系统实现 `ctx.fs`。未来的兄弟后端可以在同一接口后面提供沙箱、远程、虚拟或项目范围的文件系统。 +第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 -第一个消费方刻意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read`、`write` 和 `edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监听或更高层的项目操作,只要所需能力存在于 `ctx.fs` 上,就无需改动本地后端包。直接目录列表后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。 +第一个消费方有意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read`、`write` 和 `edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监视或更高层的项目操作,只要 `ctx.fs` 上存在所需能力,就无需改动本地后端包。直接目录列表后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。 -文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基础目录解析相对路径,但隔离策略是独立决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 +文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 -先读后写/编辑与已观察状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。详见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 与 [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。 +读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。 ## 包拓扑 @@ -47,11 +47,11 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` consumer interface implementation ``` -`@deepseek-ai/dsh-fs` 仅依赖 `cordis` 和来自 `@deepseek-ai/dsh-llm` 的仓库级 `HarnessError` 基类。它声明 `ctx.fs` 键、抽象 `FileSystem` 服务、后端与消费方共享的词汇类型、文件系统错误词汇,以及 `fs/*` 策略事件词汇。它不持有已观察状态存储,也不持有 owner 推导形态;事件传递一个不透明的 `object` actor,提供方从不读取它,`dsh-fs-policy` 插件在这些事件之上拥有 owner 推导形态和已观察状态存储。 +`@deepseek-ai/dsh-fs` 仅依赖 `cordis` 加上来自 `@deepseek-ai/dsh-llm` 的仓库级 `HarnessError` 基类。它声明 `ctx.fs` 键、抽象 `FileSystem` 服务、后端和消费方共享的词汇类型、文件系统错误词汇,以及 `fs/*` 策略事件词汇。它不持有观测状态存储,也不持有 owner 推导形态;事件传递一个不透明的 `object` actor,提供方从不读取它,`dsh-fs-policy` 插件在这些事件之上拥有 owner 推导形态和观测状态存储。 -`@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs` 和 `cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基础目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有已观察状态存储:新鲜度是后端铸造、策略插件记录的版本令牌。 +`@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs` 和 `cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有观测状态存储——新鲜度是后端铸造、策略插件记录的版本令牌。 -`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 `cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs`、`node:path` 或 `@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs`。 +`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 `cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs`、`node:path` 或 `@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs` 中。 根 `tool-fs` 插件通过组合各工具的注册辅助函数来注册完整的文件系统工具套件(`read`、`write` 和 `edit`)。它注入 `fs`,从不导入实现包。 @@ -59,23 +59,23 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` `@deepseek-ai/dsh-fs` 拥有一个语义文件系统服务。它比 `readFile` / `writeFile` 更高层,这样 `tool-fs` 就不必重新实现路径解析、版本管理、文本解码、二进制拒绝、分页、原子替换、符号链接行为或字面编辑语义。 -该接口覆盖以下语义操作: +该接口涵盖以下语义操作: - 将模型/插件提供的路径解析为后端定义的目标。 -- 在不读取文件内容的情况下获取目标元数据。 +- 获取目标元数据而不读取文件内容。 - 从目标读取有界的 UTF-8 文本页。 - 创建或替换一个 UTF-8 文本文件。 -- 通过字面替换编辑一个已存在的 UTF-8 文本文件。 +- 通过字面替换编辑一个已有的 UTF-8 文本文件。 -提供方 seam 还承载策略所依赖的新鲜度钩子,但已观察状态存储和 owner 推导位于 `dsh-fs-policy` 插件中,而非 `ctx.fs` 上: +提供方 seam 还携带策略所依赖的新鲜度钩子——但观测状态存储和 owner 推导位于 `dsh-fs-policy` 插件中,而非 `ctx.fs` 上: -- 后端为每个目标铸造一个不透明的 `version` 令牌(在 `stat` 和每次读取/变更结果中)。 -- `writeText`/`editText` 接受一个可选的版本期望:省略它则执行无条件的裸提供方变更,提供它则在后端的原子临界区内守护变更。 -- `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录已观察版本,以从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。 +- 后端为每个目标铸造一个不透明的 `version` 令牌(在 `stat` 以及每次读取/变更结果中)。 +- `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更;提供它则在后端的原子临界区内守护变更。 +- `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以它从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。 -授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都记录目标的版本,后续的写入/编辑只要文件仍处于该版本即被授权——因此对第 100-150 行的窗口读取可以授权对第 120 行的编辑。已观察状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。) +授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。) -路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目范围的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。 +路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。 解析后的目标必须至少暴露三个概念: @@ -83,19 +83,19 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - 不透明的 `targetKey`,用于过期守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 -读取和变更结果必须包含一个不透明的文件 `version`。本地后端可以使用 mtime/size 或类 hash 令牌;远程后端可以使用修订 id。`dsh-fs-policy` 插件记录版本用于过期检查;消费方可以展示相关元数据但禁止解释版本令牌。 +读取和变更结果必须包含不透明的文件 `version`。本地后端可以使用 mtime/size 或类似 hash 的令牌;远程后端可以使用 revision id。`dsh-fs-policy` 插件记录版本用于过期检查;消费方可以展示相关元数据但禁止解释版本令牌。 -提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 以流式传输相同的文本语义用于大文件。二者都负责常规文件检查;有界行/输出处理不是它们的职责——行窗口、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。 +提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。 -已观察状态记录不在 `ctx.fs` 上:成功读取后执行器发出 `fs/observed`,`dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。 +观测状态记录不在 `ctx.fs` 上:成功读取后,执行器发出 `fs/observed`,`dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。 -全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已存在的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并在已存在时以 `FS_NOT_OBSERVED` 拒绝(这是策略为未观察 owner 使用的路径);`replaceIfVersion` 仅在目标处于已观察版本时替换,否则 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的已观察状态选择提供哪个期望。 +全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已有的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`,这是策略为未观测 owner 使用的路径);`replaceIfVersion` 仅在目标处于观测版本时替换,否则报 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。 -字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的过期版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;过期检查在字面匹配之前运行,因此针对旧读取的编辑会报告 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地式组合。 +字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的过期版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;过期检查在字面匹配之前运行,因此基于旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地风格的组合。 -策略插件(而非 `ctx.fs`)对先前观察进行门控:`edit` 要求 owner 有先前观察(否则 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传递给 `editText`。在策略插件缺席时,`ctx.fs` 单独是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。 +策略插件(而非 `ctx.fs`)对先前观测进行门控:`edit` 要求 owner 有先前观测(否则报 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传给 `editText`。在策略插件缺席时,`ctx.fs` 本身是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。 -文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码为 `FS_NOT_FOUND`、`FS_NOT_TEXT`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_NOT_REGULAR_FILE`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND` 和 `FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已移除。目录列表相关的错误码后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。) +文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码包括 `FS_NOT_FOUND`、`FS_NOT_TEXT`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_NOT_REGULAR_FILE`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND` 和 `FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已删除。目录列表相关的错误码后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。) ## 工具消费方行为 @@ -105,7 +105,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - `read`:检查一个 UTF-8 文本文件并返回带行号的内容与分页引导。 - `write`:创建或完全替换一个 UTF-8 文本文件。 -- `edit`:通过替换字面文本更新一个已存在的 UTF-8 文本文件,默认要求唯一匹配,并允许显式的全部替换模式。 +- `edit`:通过替换字面文本更新一个已有的 UTF-8 文本文件,默认要求唯一匹配,并允许显式的全部替换模式。 每个工具遵循相同的执行形态: @@ -114,47 +114,47 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 3. 将结果格式化为面向模型的 `ContentBlock[]`。 4. 让抛出的后端/工具错误流经 `ToolRegistry.execute()`,由其转换为 `isError` 工具结果。 -该包通过 `ctx.systemPrompt.section(...)` 注册提示词引导,通过 `ctx.tools.register(...)` 注册 schema。工具 schema 仍通过 `SystemPrompt.assemble()` 和 `ToolRegistry.schemas()` 流入正常的提示词组装路径;无需修改 agent loop。 +该包通过 `ctx.systemPrompt.section(...)` 注册提示词引导,通过 `ctx.tools.register(...)` 注册 schema。工具 schema 仍通过 `SystemPrompt.assemble()` 和 `ToolRegistry.schemas()` 流入正常的提示词组装路径;无需改动 agent loop(智能体循环)。 -工具包在后端变化时保持面向模型的契约稳定:本地后端和远程后端内部可能以不同方式解析路径,但 `read` / `write` / `edit` 的 schema 不会仅因后端变化而改变。 +工具包在后端变化时保持面向模型的契约稳定:本地后端和远程后端内部可能以不同方式解析路径,但 `read` / `write` / `edit` schema 不会仅因后端变化而改变。 -默认部署要求在用 `write` 或 `edit` 更新已存在文件之前先 `read`。`tool-fs` 不通过检查名为 `read` 的工具是否运行过来实现这一点:它分发 `fs/write-intent`/`fs/edit-intent` 事件(将执行上下文作为不透明 actor 传递),`dsh-fs-policy` 插件推导 owner、对先前观察进行门控并提供版本期望。任何窗口读取都能授权后续的写入/编辑,只要文件未变。用 `write` 创建新文件不要求先前观察。 +默认部署要求在用 `write` 或 `edit` 更新已有文件之前先 `read`。`tool-fs` 不通过检查是否运行过名为 `read` 的工具来实现这一点:它分发 `fs/write-intent`/`fs/edit-intent` 事件(将执行上下文作为不透明 actor 传递),`dsh-fs-policy` 插件推导 owner、对先前观测进行门控并提供版本期望。任何窗口化读取都能授权后续的写入/编辑,只要文件未变。用 `write` 创建新文件不要求先前观测。 根插件通过组合各工具的注册辅助函数来注册完整套件。它注入 `fs`、`tools` 和 `systemPrompt`。 ## 测试 -测试遵循包边界,而非仅覆盖用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件与版本守护写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中针对真实本地提供方的消费方接口(仅 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有无 `dsh-fs-policy` 两种情况下的集成测试,通过从磁盘回读文件来验证世界状态,而非信任返回的 `ContentBlock[]`。已观察状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。 +测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,而非信任返回的 `ContentBlock[]`。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。 本仓库曾踩过的防御性模式类别被直接固定: -- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only(`'wx'`、`0o600`)临时文件暂存,失败时清理,最后原子 rename。这与 bash spill-file 规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限以及已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。 -- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个已观察状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的先读守护,通过一个路径的过期写入可通过另一个路径检测到。 -- **并发/过期竞争。** 两个并发的写入/编辑操作针对同一目标确定性地结算:一个成功,另一个以 `FS_STALE_VERSION` 被拒绝;成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。 -- **HMR 安全与 dispose。** 释放后端的 fiber 会撤回 `ctx.fs` 提供方;后续提供方启动时没有继承的状态。 +- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only(`'wx'`、`0o600`)临时文件暂存,失败时清理,最后原子 rename——与 bash 溢出文件规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。 +- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的过期写入可通过另一个路径检测到。 +- **并发/过期竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个被 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。 +- **HMR(热模块替换)安全与 dispose(资源释放)。** dispose 后端的 fiber 会撤回 `ctx.fs` 提供方;后续的提供方以无继承状态启动。 ## 曾考虑的替代方案 -- **面向模型的工具直接使用 `node:fs`**:工具包将同时拥有执行策略、路径解析、原子写入、文本解码和编辑语义,耦合了「问题」一节所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 -- **单一合并包 `dsh-fs-tools`**:seam 之前的形态;出于与 bash 相同的接口/实现/消费方拆分理由被否决,且合并名称从未成为公开接口。 -- **已观察状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观察策略,因此提供方仅保留版本令牌和可选的版本守护变更。 +- **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 +- **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。 +- **观测状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 ## 后果 -**`cwd` 可能被误认为沙箱。** 本地后端的基础目录是解析默认值,而非自动的隔离边界。如果需要隔离,必须由后端契约或 `tools/execute` 上的权限/沙箱插件强制执行。 +**`cwd` 可能被误认为沙箱。** 本地后端的基目录是解析默认值,而非自动的隔离边界。如果需要隔离,必须由后端契约或 `tools/execute` 上的权限/沙箱插件强制执行。 -**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露展示元数据,而不要求消费方理解宿主路径。 +**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露显示元数据,而不要求消费方理解宿主路径。 -**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义。这会重新制造本 RFC 试图避免的耦合。 +**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 RFC 试图避免的耦合。 -**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地结算:一个赢,另一个得到 `FS_STALE_VERSION`。 +**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`。 -**已观察状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内;split-fs-seam RFC 随后确立:沙箱/远程后端不应继承面向模型的观察策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 仅保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、已观察状态和先读后编辑门控,通过 `fs/*` 事件实现。 +**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内部;split-fs-seam RFC 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 -**`resolve` 后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再作为单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端而言这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每一步变为独立请求,使单次 `read` 变成两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观察契约不变。 +**`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测契约不变。 -**已观察状态持久化被推迟。** 已观察状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观察可回放。 +**观测状态持久化被推迟。** 观测状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观测可回放。 -**错误码成为 seam 的一部分。** `FsError` 错误码使过期版本和观察失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs` 从 `dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且仅限于错误词汇。 +**错误码成为 seam 的一部分。** `FsError` 错误码使过期版本和观测失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs` 从 `dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且限于错误词汇。 -**包拆分的代价前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改变包接口代价更高。 +**包拆分的成本前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改包接口代价更高。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml index 85783f6ea4..27f4b260ae 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-agent-lifecycle-and-ownership-seams.md: a70e7db8d809efd68ae770995795fc7b3d1b83d2 -2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: ac42a09c70e9570d3def0f0bd056bd571b923315 +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 3fb68336c56b5296f18b3587ea42399a05362733 diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md index ac42a09c70..3fb68336c5 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -1,12 +1,12 @@ # RFC:Agent 生命周期与所有权 seam -Status: implemented - [English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文 +Status: implemented + ## 问题 -ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 seam 的不同症状:插件可以通过 `ctx.agents` 创建或恢复 agent,但无法独立拥有并 dispose(资源释放)单个 agent;长时间运行的 bash 任务在执行器内部也没有稳定的所有者。ACP 在断开连接时中止并等待 agent,却无法只注销该会话的 agent;`session/cancel` 无法取消已排队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。 +ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 seam 的症状:插件可以通过 `ctx.agents` 创建或恢复 agent(智能体),但无法独立拥有和 dispose(资源释放)单个 agent,而长时间运行的 bash 任务在执行器中也没有稳定的所有者。ACP 在断连时中止并等待 agent,却无法仅注销该会话的 agent;`session/cancel` 无法取消已入队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。 ## 决策 @@ -14,33 +14,33 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se ### 1. 队列感知的 `Agent.cancel(reason?)` -`cancel()` 是唯一的公开停止原语。它清除已排队的输入和 steering(中途引导)输入,中止正在执行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已排队的提示词在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲 agent 的 cancel 不设置标记。 +`cancel()` 是唯一的公开停止原语。它清除已入队和 steering(中途引导)输入、中止正在进行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已入队的 prompt 在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲状态的 cancel 不设置标记。 ### 2. `AgentHandle` 异步释放器 -`ctx.agents.create`/`resume` 与 `AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法拆除它。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享同一个 memoized 的拆除流程:停止循环、等待静默与 flush 完成、分离 agent 和会话,然后回收其 scope。注册表条目分离后 ID 即可复用。由配置创建的 agent 归 loop fiber 所有;ACP 存储并 dispose 每个会话的 handle。 +`ctx.agents.create`/`resume` 和 `AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法将其拆除。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享一个 memoize 的拆除过程:停止循环、等待静默与刷写完成、分离 agent 和会话,然后解除其 scope。ID 在注册表条目分离后变为可复用。由配置创建的 agent 归 loop fiber 所有;ACP 存储并 dispose 每个会话的 handle。 -拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放先停止循环并等待 `agent.done`,再分离会话。如果使用兄弟 effect,它们会并发释放,可能在关闭 flush 之前移除 append 钩子。释放通知被隔离,不会中断拆除链。 +拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放会先停止循环并等待 `agent.done`,然后再分离会话。若使用兄弟 effect,则会并发释放,可能在关闭刷写之前就移除 append 钩子。释放通知被隔离,不会中断拆除链。 -### 3. Bash 所有者令牌置于 seam 中 +### 3. Bash seam 中的所有者令牌 -后台任务的所有权归执行器持有。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` 与 `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保留在任务上,使得这道围栏在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。 +后台任务的所有权属于执行器。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` 和 `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保存在任务上,使得这道隔离在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。 ## 验证 -- ACP 断开连接或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。 -- 在已排队的提示词启动前取消,能阻止该提示词运行或吸收下一条提示词。 +- ACP 断连或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。 +- 在已入队的 prompt 启动前取消,能阻止该 prompt 运行或吸收下一条 prompt。 - 重载 `dsh-tool-bash` 不会让另一个会话读取或终止已有的后台任务,因为所有权保留在执行器上。 -- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 的演示无需显式管理 handle。 +- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 演示无需显式管理 handle。 ## 会话所有者令牌在存活 agent 中唯一 -bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务会回滚。`tool-bash` 拥有比较策略;bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。 +bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务回滚。`tool-bash` 拥有比较策略;bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。 ## 曾考虑的替代方案 - **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` seam:否决。一条读取路径即可,无需冗余 API。 -- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时兄弟 effect 并发释放(`Promise.all`),store 持有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 +- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 - **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 RFC](../simplification/2026-06-20-public-agent-stop-surface.md))。 ## 后果 diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml index 0e2a4891d9..98b4ee0e59 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-session-surface.md: 31297166b735468147850a81d7fd43a8fa30a1e8 -2026-06-18-session-surface.zh.md: 9e2933a1e564b3dbb7719d55b5264f670c3b833f +2026-06-18-session-surface.zh.md: 159aefc10261ac5380701f46c3d4a367674940b1 diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md index 9e2933a1e5..159aefc102 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md @@ -1,22 +1,22 @@ -# RFC:Session surface——基于事件日志的链表,用于 LLM 消息推导 - -Status: implemented +# RFC:会话 surface——基于事件日志的链表,用于 LLM 消息派生 [English](2026-06-18-session-surface.md) | 中文 +Status: implemented + ## 问题 -事件日志是权威数据源,但历史操作此前没有持久化的共享机制。如果没有这样的机制,上下文压缩(context compaction)等插件只能通过顺序敏感的监听器改写派生请求,不留溯源记录,且每次新增操作都要修改 `deriveMessages()`。 +事件日志是权威数据源,但历史操纵此前没有持久化的共享机制。如果没有这样的机制,上下文压缩(context compaction)等插件只能通过顺序敏感的监听器改写派生请求,不留溯源信息,且每次新增操纵都要反复修改 `deriveMessages()`。 ## 决策 -新增一个 **surface**:一条从事件日志派生、带缓存的链表,由「surface 节点」(即产出 LLM 消息的那部分事件)组成,通过事件日志中的 `surfaceOp` 标记维护。 +新增一个 **surface**:一条派生的、缓存的链表,由「surface 节点」(事件中产出 LLM(大语言模型)消息的子集)组成,通过事件日志中的 `surfaceOp` 标记维护。 -### `SessionEvent` 上的两个新顶层字段 +### `SessionEvent` 新增两个顶层字段 -每个 `SessionEvent` 新增两个可选字段(与 `seq`/`time` 同属结构元数据): +每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级): -- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如:构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。 +- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。 - **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。 ### SurfaceOp:两种操作 @@ -27,45 +27,45 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive ``` -1. **Append**:在尾部追加一个新节点。`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message` 使用此操作。agent loop 在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时附带 `sourceEventSeqs`(例如 `assistant/message` 记录其 `assistant/chunk` 来源;`tool/result` 记录其 `tool/call` 来源)。 +1. **Append**:在尾部追加一个新节点。`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时附带 `sourceEventSeqs`(例如 `assistant/message` 记录其 `assistant/chunk` 来源;`tool/result` 记录其 `tool/call` 来源)。 -2. **Replace**:移除从 `start` 到 `end`(两端含)的节点,并在其位置插入一个新节点。`start` 和 `end` 都必须是当前 surface 上有效的 surface 节点 seq;`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍保留在日志中,但不再出现在 surface 上。 +2. **Replace**:移除从 `start` 到 `end`(两端包含)的节点,并在其位置插入一个新节点。`start` 和 `end` 都必须是当前 surface 上有效的 surface 节点 seq;`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。 ### SurfaceManager:基于增量,而非全量重建 -`SurfaceManager` 类(`Session` 的私有实现)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**(上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前事件不会改变;种子日志只是在首次访问时折叠的初始增量。 +`SurfaceManager` 类(`Session` 私有)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**(自上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前的事件不会改变;种子日志只是在首次访问时折叠的初始增量。 无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。 -`deriveMessages()` 在存在 surface 标记时使用 surface,否则回退到既有的线性扫描(向后兼容)。 +`deriveMessages()` 在存在 surface 标记时使用 surface,对没有标记的会话回退到既有的线性扫描(向后兼容)。 ### 持久化 -新字段作为顶层 JSON 属性序列化。JSONL 后端无需任何修改:`JSON.stringify`/`JSON.parse` 透明地保留一切。SQLite 后端的 `events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`)。磁盘上的 `SCHEMA_VERSION` 递增以反映列集变化,并且按照预发布的 bump-and-reject 策略,由其他构建写入的数据库在打开时被拒绝,而非迁移(没有需要升级的持久化用户数据)。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`(「不稳定/预发布」立场):可选的 surface 字段被吸收而不递增版本号。 +新字段作为顶层 JSON 属性序列化。JSONL 后端无需任何改动:`JSON.stringify`/`JSON.parse` 透明地保留一切。SQLite 后端的 `events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`)。磁盘上的 `SCHEMA_VERSION` 递增以反映列集变化,并且按照预发布的 bump-and-reject 策略,由其他构建写入的数据库在打开时被拒绝而非迁移(没有需要升级的持久化用户数据)。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`(「不稳定/预发布」立场):可选的 surface 字段被吸收而不递增版本号。 ### 崩溃恢复 -`repair.ts` 模块在崩溃后为孤立的工具调用合成 `tool/result` 关闭事件。这些关闭事件携带 `surfaceOp: 'append'` 和指向孤立 `tool/call` 事件的 `sourceEventSeqs`,确保重建后的 surface 有效。 +`repair.ts` 模块在崩溃后为孤立的工具调用合成 `tool/result` 闭合事件。这些闭合事件携带 `surfaceOp: 'append'` 和指向孤立 `tool/call` 事件的 `sourceEventSeqs`,确保重建的 surface 有效。 ### 不变式 -开发模式不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq)以及 `surfaceOp`(replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。 +开发模式下的不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq)以及 `surfaceOp`(replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。 -每个 surface 可达事件都必须携带 `surfaceOp`,否则它会从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此要求;`append` 和种子构造函数中的运行时检查覆盖了宽化联合类型和加载的日志。无效种子在预发布格式策略下被拒绝而非升级。 +每个 surface 可达事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。按照预发布格式策略,无效的种子被拒绝而非升级。 ## 曾考虑的替代方案 -- **逐插件的 `agent/request` 包装**(surface 之前的历史操作模式):监听器排序脆弱,不留持久化的变更记录,且每次新增操作都要修改核心 `deriveMessages()`。 +- **逐插件的 `agent/request` 包装**(surface 之前的历史操纵模式):监听器排序脆弱、无法持久记录改动内容,且每种新操纵都迫使核心 `deriveMessages()` 再次修改。 - **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。surface 是双向链表,端点自然以节点 seq 命名,单节点替换(`start === end`)在闭区间语义下读起来更自然。 -- **脏标记触发全量重建**而非增量处理:在会话生命周期内为 O(N²)——每次单事件追加都要重新扫描所有先前事件。 +- **脏标记后全量重建**替代增量处理:在会话生命周期内为 O(N²),每次单事件追加都要重新扫描所有先前事件。 ## 后果 -- **`packages/core/session`**:新增 `surface.ts`(`SurfaceManager`)、新类型(`SurfaceOp`、`SurfaceIntent`)、`SessionEvent` 上的新字段、修改 `append()`(第三个必需参数 `SurfaceIntent`)、重构 `deriveMessages()`(以 surface 遍历作为唯一推导路径)、surface 感知的 `repair.ts`。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。 -- **`packages/core/agent-loop`**:所有 surface 可达的追加传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 +- **`packages/core/session`**:新增 `surface.ts`(`SurfaceManager`)、新类型(`SurfaceOp`、`SurfaceIntent`)、`SessionEvent` 新字段、修改 `append()`(第三个必选参数 `SurfaceIntent`)、重构 `deriveMessages()`(以 surface 遍历作为唯一派生路径)、surface 感知的 `repair.ts`。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。 +- **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 - **`packages/session-persistence/session-persistence-sqlite`**:`events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`);`SCHEMA_VERSION` 递增(bump-and-reject,无迁移)。 -- **`packages/support/invariants`**:surface 相关的验证规则。 -- **`packages/session-persistence/session-persistence-jsonl`**:无需修改。 +- **`packages/support/invariants`**:surface 相关验证规则。 +- **`packages/session-persistence/session-persistence-jsonl`**:无需改动。 - **`packages/session-persistence/session-persistence`**:抽象接口不变。 -Surface 是未来历史操作的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点取代该范围在 surface 上的位置,而插件自身的跟踪事件(如 `compaction/start`、`compaction/end`)则不进入 surface。回放确定性地保留这一决策。 +Surface 是未来历史操纵的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 566ce4ae59..f693539b91 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-shared-persistence-write-coordinator.md: 3fc30dc2e1382fd983d050433123f46a2cd0ed19 -2026-06-18-shared-persistence-write-coordinator.zh.md: 6c8ccef8dd5603d837dd0a9884adf9c1cd8a17d4 +2026-06-18-shared-persistence-write-coordinator.zh.md: 38e900d38cc48317836717ddeda5323cf97df993 diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 6c8ccef8dd..38e900d38c 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -1,44 +1,44 @@ # RFC:共享持久化写入协调器 -Status: implemented - [English](2026-06-18-shared-persistence-write-coordinator.md) | 中文 +Status: implemented + ## 问题 -`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但二者的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、串行化 flush 链、HMR(热模块替换)种子注入,以及 dispose(资源释放)排空。纯粹的种子前缀冲突与可串行化守卫已经迁入 seam 包;剩余的编排仍然是正确性密集的,并且相同的修复被应用了两次。代码级 diff 表明两个后端在**所有**这些逻辑上是逐字节一致或同算法的:四个 map(`states`/`buffers`/`chains`/`inits`)、`installWritePath`、`initFor`、`onCreated` 的四种分支、`flush`、`drain`、`serialize`、`adopt`、`adoptLivePrefix`、`assertVersion`,以及 `create`/`append`/`load` 骨架。唯一不同的只有存储原语(写字节 vs. INSERT 行)。 +`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但它们的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、序列化的 flush 链、HMR(热模块替换)种子注入与 dispose(资源释放)排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。代码级 diff 表明两个后端在**全部**这些逻辑上要么字节相同、要么算法相同:四个 map(`states`/`buffers`/`chains`/`inits`)、`installWritePath`、`initFor`、`onCreated` 的四种分支、`flush`、`drain`、`serialize`、`adopt`、`adoptLivePrefix`、`assertVersion`,以及 `create`/`append`/`load` 的骨架。唯一的差异在于存储原语(写字节 vs. INSERT 行)。 ## 决策 -将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。 +将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。 -组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险点——「协调器不得迫使非常规后端与继承层级搏斗」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 +组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 ### 钩子接口(`PersistenceBackend<TornMarker>`) -六个方法(五个必需 + 一个可选生命周期钩子)——协调器与存储之间唯一的 seam: +六个方法(五个必需 + 一个可选的生命周期钩子)——协调器与存储之间唯一的 seam: -- `name`:后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`:按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucket;SQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 实现创建冲突探测。 -- `loadLive(id, cwd)`:读取**限定于 `cwd`** 的已存储前缀。**刻意区别于 `loadStored`**:HMR live-adoption 只能接管与活跃会话**相同 cwd** 下的持久化日志;同 id 但不同 cwd 的日志是冲突而非恢复。合并这两个方法会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`。 -- `appendBatch(meta, events, isMaterialized)`:持久地追加一个连续批次,在尚未物化时**原子地**惰性物化会话(物化写入与第一个事件批次必须一起提交——崩溃发生在二者之间时不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 -- `commitRepair(meta, tornMarker, closers)`:使崩溃修复持久化:截断撕裂尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 -- `list()`:列出所有已存储的元数据。 -- `close?()`:可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空**之后**被 await,确保 close 失败不会掩盖排空错误。 +- `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 +- `loadStored(id)`——按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucket;SQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 进行创建碰撞探测。 +- `loadLive(id, cwd)`——读取**限定于 `cwd`** 的已存储前缀。**与 `loadStored` 有意区分**:HMR live-adoption 只能接管与存活会话处于**同一 cwd** 的持久化日志;同 id 但不同 cwd 的日志是碰撞而非恢复。合并二者会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`。 +- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时**原子地**惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 +- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 +- `list()`——列出所有已存储的元数据。 +- `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空**之后**被 await,因此 close 失败不会掩盖排空错误。 -### 不透明的撕裂标记 +### 不透明的 torn marker -保持 seam 干净的唯一设计选择:崩溃修复中的「撕裂尾部在哪里」token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的标记类型:JSONL 使用要截断到的字节偏移量,SQLite 使用要从其开始删除的 seq(两者碰巧都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较**折叠在钩子内部**,因此返回的标记已经是 `number | undefined`;如果不做这个折叠,协调器就必须了解字节长度。 +保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 使用要截断到的字节偏移,SQLite 使用要从其开始删除的 seq(两者恰好都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较折叠**在钩子内部**,因此返回的 marker 已经是 `number | undefined`;如果不做这层折叠,协调器就必须了解字节长度。 ## 测试 -共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、冲突、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个 through-coordinator 的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture 钩子),确保协调器的撕裂标记修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生撕裂标记,因此无法触达该分支。 +共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、碰撞、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试规格缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个经由协调器的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture(测试前置数据)钩子),确保协调器的 torn-marker 修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生 torn marker,因此无法触达该分支。 ## 曾考虑的替代方案 -- **后端继承的基类**:否决,改用组合。后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 -- **更宽的钩子面**:每个候选钩子都被折叠掉了:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与第一个事件批次原子提交);没有单独的创建冲突探测(它就是 `loadStored(id) !== undefined`);`list()` 也不经过协调器透传(列举不需要任何编排)。 +- **后端继承的基类**——否决,改用组合:后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍可完全不使用协调器、直接实现抽象服务。 +- **更宽的钩子面**——每个候选钩子都被折叠掉:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与首批事件原子提交);没有单独的创建碰撞探测(即 `loadStored(id) !== undefined`);`list()` 也不经由协调器透传(列举不需要任何编排)。 ## 后果 -协调器增加了一层间接和一个不透明的撕裂标记,但将此前每个后端重复的正确性密集编排集中到一处。其钩子面保持窄小:冲突检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,无需复制事件-缓冲区-flush 生命周期。 +协调器增加了一层间接和一个不透明的 torn marker,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。其钩子面保持窄小:碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index 52a3ca3b01..9fc5b9299f 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-branded-ids.md: f6d066857d8904ae5343f12310266663806a0ae2 -2026-06-20-branded-ids.zh.md: 14f82c395cdf43e2f5df5b2317dda3d45c595a64 +2026-06-20-branded-ids.zh.md: 80c158e598f3007416d31a89a6704a759798e44e diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md index 14f82c395c..80c158e598 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -1,30 +1,30 @@ -# RFC:在所有应当使用品牌类型的位置推行 Branded ID - -Status: implemented +# RFC:在所有应有之处使用 branded ID [English](2026-06-20-branded-ids.md) | 中文 +Status: implemented + ## 问题 -harness 已经为三个标识符打上了品牌类型:`CallId`(`packages/llm/llm/src/brand.ts`)、`SessionId`(`packages/core/session/src/types.ts`)和 `AgentId`(`packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制(由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零成本的 cast 工厂函数。`dsh-brand` 还声明了治理策略:*"品牌类型用于跨包边界且可能被混淆的 id;并非每个 string 都需要品牌类型。"* 这条策略是正确的;问题在于它只落实了一半。两个缺口使得「结构相同但语义不同」的 string 今天仍能通过类型检查。 +harness 已经为三个标识符做了 brand 处理:`CallId`(`packages/llm/llm/src/brand.ts`)、`SessionId`(`packages/core/session/src/types.ts`)和 `AgentId`(`packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制(由纯类型包(package) `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*"Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。"* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 -**缺口 1:bash seam 中未打品牌的 ID。** `BashTask.id` 以及所有 executor/tool 边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型也通过 `task_id` 返回该值,因此混淆 task id 和 session id 既是类型正确的,也是可达的。 +**缺口 1:bash seam 中未 brand 的 ID。** `BashTask.id` 以及所有执行器/工具边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型还通过 `task_id` 返回该值,因此混淆 task id 和 session id 既类型正确又可达。 -bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是拥有者 agent 的 `session.header.id`(`callerToken = (exec) => exec.agent?.session.header.id`,见 `packages/bash/tool-bash/src/index.ts`)——即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个「不匹配但类型正确」的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的「bash owner-token 别名漏洞」。 +bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)的 `session.header.id`(`callerToken = (exec) => exec.agent?.session.header.id`,位于 `packages/bash/tool-bash/src/index.ts`),即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的"bash owner-token alias hole"。 -**缺口 2:既有品牌类型的侵蚀。** `CallId`、`SessionId` 和 `AgentId` 在注册表 map、公开查找参数、ACP 会话追踪和持久化协调器中退化为裸 string。在查找边界丢弃品牌类型,等于废掉了它的核心保护。 +**缺口 2:既有 brand 的侵蚀。** `CallId`、`SessionId` 和 `AgentId` 在注册表 map、公开查找参数、ACP 会话跟踪和持久化协调器中退化为裸 string。在查找边界丢弃 brand 会使其主要保护失效。 ## 决策 -纯类型变更。品牌类型是零成本 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵守既有的「并非每个 string 都需要」策略。 +纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵循既有的"不是每个 string 都需要"策略。 -- **为 bash task id 打品牌。** 在 `packages/bash/bash/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂函数,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。品牌原语放在无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 只依赖它就能为自己的 id 打品牌——永远不需要为了获取 `Branded` 而引入 `dsh-llm`(或 `dsh-session`)。将品牌贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时一次性为计数器输出打品牌),以及 `dsh-tool-bash` 的校验/访问控制面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的 tool 边界处打品牌)。 +- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`(拥有该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的工具边界处被 brand)。 -- **铸造独立的 `OwnerToken` 品牌。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`)cast 为 `OwnerToken`——这是两套词汇交汇的唯一位置。bash seam 永远不导入 `dsh-session`。(理由见下一节。) +- **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash seam 从不导入 `dsh-session`。(理由见下一节。) -- **阻止品牌侵蚀。** 将既有品牌传播到缺口 2 列出的 `Map` 键类型和公开方法参数:`Map<SessionId, Session>`、`get(id: SessionId)`、`Map<AgentId, Agent>`、`Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械性最大的部分,也是让*既有*品牌在查找处真正发挥作用(而非仅在结构体字段上标注)的关键。 +- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>`、`get(id: SessionId)`、`Map<AgentId, Agent>`、`Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 -示意形状(工厂模式与现有三个品牌完全一致): +示意形状(工厂模式与已有的三个 brand 完全一致): ```ts ignore-check import type { Branded } from '@deepseek-ai/dsh-brand' @@ -46,24 +46,24 @@ export function OwnerToken(id: string): OwnerToken { ### 为什么不把 `owner` 类型标注为 `SessionId`? -executor 将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保持了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行从 `SessionId` 到 `OwnerToken` 的唯一转换。 +执行器将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保留了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行从 `SessionId` 到 `OwnerToken` 的唯一转换。 ## 不在范围内 / 可能的扩展 -遵循「并非每个 string 都需要品牌类型」策略,刻意保持窄范围。以下每项都是合理的未来品牌候选,附有推迟理由而非承诺: +遵循"不是每个 string 都需要 brand"的策略,刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺: -- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表键)——一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个品牌,仅为控制本 RFC 的影响范围而暂不纳入。 -- **`ToolName`**(`ToolRegistry` 键)——由作者定义、人类可读,且很少与其他 id 混淆;候选强度最弱,可能不值得打品牌。 -- **`ErrorCode`**(`HarnessError.code`)——封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要加强类型,用 string 字面量联合类型比品牌更合适。 -- **数值序号**——轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体为它们打品牌,但它们是位置序号、很少跨边界传递,收益低。 -- **带校验的构造**——品牌工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方发放的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界对畸形输入抛异常的 `SessionId.parse()` / `isValid()` 伴生函数确实是缺口,但它是一项*运行时行为*变更,有自己的设计问题(什么算「畸形」?失败时怎么办?),应在独立 RFC 中处理,不应捆绑进这次纯类型改动。 +- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个 brand,仅为控制本 RFC 的影响范围而暂不纳入。 +- **`ToolName`**(`ToolRegistry` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。 +- **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。 +- **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。 +- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算"格式错误"?失败时怎么办?),应在独立 RFC 中处理,不应捆绑进这次纯类型变更。 ## 验证 -`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿 executor、本地实现和面向模型的 tool,且未引入 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用对应的品牌类型而非裸 `string`;来自提供方、ACP 和模型的原始输入通过品牌工厂进入,而非散落的 cast。 +`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿执行器、本地实现和面向模型的工具,且未添加 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用相应的 brand 而非裸 `string`;来自提供方、ACP 和模型的原始输入通过 brand 工厂进入,而非散落的 cast。 ## 后果 -- **两个面上的机械性改动。** 传播品牌类型涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误,而非静默 bug。变更可观测地是纯类型的——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);即使该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 -- **品牌类型不做校验。** 品牌类型是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是格式良好的 string,就和以前一样能通过类型检查。本 RFC 不关闭这个缺口(见「不在范围内」)——它只阻止传入错误*类别*的 id 这一类错误。 -- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 打品牌而不为 `ToolName`,为 `OwnerToken` 打品牌而不为 `ModelId`,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。 +- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);如果该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 +- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 RFC 不关闭这个缺口(见"不在范围内")——它只阻止传入错误*类别*的 id 这种错误。 +- **"在哪里停下"仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string"可能被混淆"的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml index 992793d609..d10d531a66 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-extract-example-app-packages.md: 3a0a0f5d4b329afed72bd3c00bf880989e24fe54 -2026-06-20-extract-example-app-packages.zh.md: 9de9f79369ebad387778a0418b75dfde96b285a7 +2026-06-20-extract-example-app-packages.zh.md: 8945f0c97727479c9e847711a96fc5d18118e09e diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md index 9de9f79369..8945f0c977 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md @@ -1,57 +1,57 @@ -# RFC:将示例应用提取为 package - -Status: implemented +# RFC:将示例应用提取为独立包 [English](2026-06-20-extract-example-app-packages.md) | 中文 +Status: implemented + ## 问题 -示例目录本应是*薄*的:只包含演示的可变接线,而非演示的机制本身。在本次变更之前它是厚的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示还需要的 `logger` + `hmr`)、三个共享 YAML 片段的嵌套引入(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),以及每个示例各自的 `agent-loop`/持久化/系统提示词配置。真正的应用——每个 agent 都需要的服务主干——分散在叶子配置和那些 include 中。 +示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。 -叶子配置还拥有一个耦合的前门。ACP 要求 stdout 纯净,通过 `session/new` 创建 agent;stdio 需要控制台 logger 和一个预创建的 `main`。防止错误组合的唯一手段是行文中的警告,而三个 `start.ts` 文件重复了 Loader 引导和生命周期代码。 +叶子配置还拥有一个耦合的前门。ACP(Agent Client Protocol)要求 stdout 纯净,并通过 `session/new` 创建 agent;stdio 则需要一个控制台 logger 和一个预创建的 `main`。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复着 Loader 引导和生命周期代码。 ## 决策 -每个示例现在**基本上是对一个 app package 的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**app 包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM 适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。 +每个示例现在**主要是对一个应用包(package)的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**应用包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM(大语言模型)适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。 -- **`@deepseek-ai/dsh-agent-spine-demo`**([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合无提供方、无执行器、无 UI 的主干,并转发 loop 的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为这个包组合的是主干而非扩展它;替换 loop 意味着提供另一个 bundle。 -- **`@deepseek-ai/dsh-stdio-demo`**([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**([packages/examples/acp-demo](../../../../packages/examples/acp-demo))各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`;ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以追加插件,但安全的组合现在是默认产物。 -- **`start.ts` 已移除。** 每个 app 包暴露一个 `bin`(`dsh-stdio-demo` / `dsh-acp-demo`);`demo:*` 脚本调用它(如 `dsh-stdio-demo ./cordis.yml`)。Loader 引导尾部、`.env` 加载和 fail-loud 守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享 app bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个薄的自执行组合,基于这些辅助函数加上自身特有的生命周期逻辑(ACP bin:快照模式选择与 stdin-dispose)。`bin.ts` 文件本身仍排除在覆盖率之外(自执行 CLI 入口,与旧的 `start.ts` 类似),由 keyless Loader 路径测试驱动。 -- **每个叶子 `cordis.yml` 精简**为后端 + 配置:LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`)、bash 执行器(`bash-local`)、stdio 演示的 `hmr`(见下方修正),以及一个 app 条目承载 app 的配置(模型、系统提示词、持久化根目录——作为 app 包自身的 `Config` 暴露,由 app 将每个值路由到其接线的目标位置:stdio 路由到预创建的 agent,acp 路由到 bridge 插件)。 -- **echo-agent 折叠到 `dsh-stdio-demo`**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。 -- **`base.yml`、`base-core.yml` 和 `acp-agent/acp-tail.yml` 退役**——它们共享的主干现在位于 `dsh-agent-spine-demo`。 +- **`@deepseek-ai/dsh-agent-spine-demo`**([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含 provider、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。 +- **`@deepseek-ai/dsh-stdio-demo`**([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**([packages/examples/acp-demo](../../../../packages/examples/acp-demo))各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`;ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以添加插件,但安全的组合现在是默认产物。 +- **`start.ts` 已移除。** 每个应用包暴露一个 `bin`(`dsh-stdio-demo` / `dsh-acp-demo`);`demo:*` 脚本调用它(例如 `dsh-stdio-demo ./cordis.yml`)。Loader 引导尾部、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个精简的自执行组合,基于这些辅助函数加上其应用特有的生命周期逻辑(ACP bin:快照模式选择与 stdin-dispose)。`bin.ts` 文件本身仍被排除在覆盖率之外(自执行 CLI(命令行界面)入口,与旧的 `start.ts` 性质相同),由 keyless 的 Loader 路径测试驱动。 +- **每个叶子 `cordis.yml` 精简为**后端 + 配置:LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`)、bash 执行器(`bash-local`)、stdio 演示的 `hmr`(见下方修正),以及一个承载应用配置的 app 条目(模型、系统提示词、持久化根目录——以应用包自身的 `Config` 形式暴露,由它将各值路由到应用接线的目标位置:stdio 路由到预创建的 agent,acp 路由到 bridge 插件)。 +- **echo-agent 折叠到 `dsh-stdio-demo` 上**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。 +- **`base.yml`、`base-core.yml` 和 `acp-agent/acp-tail.yml` 已退役**——它们共享的主干现在位于 `dsh-agent-spine-demo` 中。 -`bash-local` 和 LLM 适配器保持为**叶子选择**:bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器可以在不触碰 app 的情况下替换进来。 +`bash-local` 和 LLM 适配器仍然是**叶子选择**:bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器无需触碰应用即可替换。 ### 实现修正:`hmr` 保留为叶子条目 -提案将 `hmr` 列入 stdio app 内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包在两方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付: +提案最初将 `hmr` 列入 stdio 应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包中会在两个方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付: -1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃 `loader` 服务的情况下会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,无法在进程内的单元/覆盖率测试层运行。 +1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃的 `loader` 服务时会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。 2. 进程内测试层(vitest)甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。 -关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患——在 ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧——因此将它留在叶子不会损失耦合论证所关注的安全性。**logger**(真正的耦合)保持内置:stdio app 包含它,ACP app 省略它。 +关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患:ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧,因此将它留在叶子层不会损失耦合论证所关注的安全性。**logger**(真正的耦合点)保持内置:stdio 应用包含它,ACP 应用省略它。 ## 曾考虑的替代方案 -### 为什么不继续用共享 YAML include 来接线? +### 为什么不继续用共享 YAML include 来管理接线? -旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水只能在三个 `start.ts` 文件中复制。包将「ACP app 绝不向 stdout 输出日志」从行文警告变成产物的属性:叶子中没有可以写错的 logger 条目。 +旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水一直在三个 `start.ts` 文件中重复。包将「ACP 应用绝不向 stdout 输出日志」从文字警告变成了产物的属性:叶子中不存在可以写错的 logger 条目。 ## 验证 - 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。 -- `demo:echo`、`demo:repl` 和 `demo:acp` 调用 app 包的 bin。 -- 每个新包有 README 和逐文件 100% 覆盖率;每个 app 包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状失败。 -- ACP 回放 transcript 保持不变,因为插件集和加载顺序未改变。 +- `demo:echo`、`demo:repl` 和 `demo:acp` 调用应用包的 bin。 +- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。 +- ACP 回放 transcript(文本记录)保持不变,因为插件集合和加载顺序未改变。 ## 后果 -- **裸插件树教学法。** echo-agent 的内联 `cordis.yml` 曾一次展示所有插件;主干现在藏在 bundle 后面,因此查看完整树意味着打开 `dsh-agent-spine-demo`。app 包的 README 承担了这部分教学职责。 -- **多了一层间接。** 「这个演示加载了什么?」变成了读一个 package,而非扫一份 YAML。 +- **裸插件树的教学性。** echo-agent 内联的 `cordis.yml` 曾一次展示所有插件;主干现在隐藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`。应用包的 README 承担了这份教学职责。 +- **多了一层间接。**「这个演示加载了什么?」从扫描单个 YAML 变成了阅读一个包。 ## 相关 -- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无提供方核心便不再有意义。 -- 建立在[能力 seam](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分之上——后端和展示层保持为叶子选择;主干是共享 bundle。 -- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放 app 特有的前门)。 +- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。 +- 基于 [capability-seams](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。 +- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放应用特有的前门)。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml index 9f89c454fc..08ab9fc7f0 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-package-hierarchy.md: faf5815222b20699a32f1af489e625ba3e891230 -2026-06-20-package-hierarchy.zh.md: 4b71cd41826e727eea19d305635c6485e18393c2 +2026-06-20-package-hierarchy.zh.md: 118367f2655bffbd270f259df4ade37a70dcb2d7 diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md index 4b71cd4182..118367f265 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -1,4 +1,4 @@ -# RFC:将包(package)重组为模块化层级结构 +# RFC:将包重组为模块化层级结构 [English](2026-06-20-package-hierarchy.md) | 中文 @@ -6,13 +6,13 @@ Status: implemented ## 问题 -`packages/` 原先是扁平的:18 个包全部位于 `packages/<name>/`,一个包的位置无法体现它是核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。package README 带着 `FIXME(package-hierarchy)`,`scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑,看起来都同等基础。 +`packages/` 原先是扁平的:18 个包(package)全部位于 `packages/<name>/`,从路径上完全看不出一个包属于核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。包的 README 带着 `FIXME(package-hierarchy)`,`scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来同样基础。 -这不仅是外观问题。因为每个顶层包看起来都属于同一个公开接口面,未来移除更难;发布/lint/文档脚本不得不通过注释或手工维护的静态列表来编码意图,而非从布局直接读取。 +这不仅仅是外观问题。由于每个顶层包看起来都属于同一个公开接口,未来移除更加困难,而 publish/lint/doc 脚本不得不通过注释或手工维护的静态列表来编码意图,而不是从布局中直接读取。 ## 决策 -按模块角色分组,统一为 `packages/<group>/<pkg>/` 两层深度。分组目录是纯容器(没有 `package.json`);每个包保留其 `@deepseek-ai/dsh-<pkg>` 名称——这是仓库结构与维护策略,不是包重命名。 +按模块角色将包分组,统一放在 `packages/<group>/<pkg>/` 深度。分组目录是纯容器(没有 `package.json`);每个包保留其 `@deepseek-ai/dsh-<pkg>` 名称——这是仓库结构与维护策略的调整,不是包的重命名。 ```text packages/ @@ -44,32 +44,32 @@ packages/ ### 放置决策 -- **能力族使用同名嵌套。** 一个族的接口包位于 `packages/<group>/<group>/`(`llm/llm`、`bash/bash`、`session-persistence/session-persistence`),实现和消费方作为扁平兄弟。不设额外的 `adapters/`/`impls/` 子层——每个包恰好在深度 2,workspace glob 保持简洁的 `packages/*/*`,一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包(目录名唯一,使 first-on-disk-wins 无歧义)。 -- **`session` 留在 `core/`;持久化自成一族。** 会话日志是核心产品 API。其存储后端构成一个平行的能力族(`session-persistence/`),与 `llm/` 和 `bash/` 对称,而非嵌套在 `core/session/` 下。 -- **`agent-loop` 在 `core/` 中。** 它是 `agent` seam 唯一的具体实现,但作为 harness 的默认产品循环随产品发布,因此与核心主干同住。插件仍然依赖 `agent` 的词汇,从不依赖 `agent-loop`,因此循环仍可替换。 -- **`invariants` 和 `ui-stdio` 属于 `support/`,不是产品。** `invariants` 是开发模式的契约检查。`ui-stdio` 从示例中提取以便复用和满足覆盖率门禁——它与示例耦合,因此与 `llm-replay`(快照测试回放适配器)一起放在 `support/` 中。`acp` 是 `ui/` 的唯一成员,因为它是真正的产品接口面(编辑器驱动的 ACP 桥接),在结构上不同于 readline 演示辅助工具。 +- **能力族使用同名嵌套。** 一个族的接口包位于 `packages/<group>/<group>/`(`llm/llm`、`bash/bash`、`session-persistence/session-persistence`),实现和消费方作为扁平兄弟并列。不设额外的 `adapters/`/`impls/` 子层——每个包恰好在深度 2,这使 workspace glob 保持简洁的 `packages/*/*`,并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包(唯一的目录名使 first-on-disk-wins 无歧义)。 +- **`session` 留在 `core/`;持久化独立成族。** 会话日志是核心产品 API。其存储后端构成一个平行的能力族(`session-persistence/`),与 `llm/` 和 `bash/` 对称,而非嵌套在 `core/session/` 下。 +- **`agent-loop` 在 `core/` 中。** 它是 `agent` seam 唯一的具体实现,但作为 harness 的默认产品循环交付,因此与核心主干同处。插件仍然依赖 `agent` 的词汇,从不依赖 `agent-loop`,所以循环仍可替换。 +- **`invariants` 和 `ui-stdio` 属于 `support/`,不是产品。** `invariants` 是开发模式的契约检查。`ui-stdio` 从示例中提取出来以便复用和满足覆盖率门禁——它与示例耦合,因此与 `llm-replay`(快照测试的回放适配器)一起放在 `support/` 中。`acp` 是 `ui/` 的唯一成员,因为它是真正的产品接口(编辑器驱动的 ACP 桥接),与 readline 演示辅助工具在结构上截然不同。 -### 去重包清单 +### 去重包列表 -包清单此前在五处重复枚举。统一的深度 2 布局使大部分可以被推导出来: +包列表此前在五个地方重复枚举。统一的深度 2 布局使大部分可以被推导: -- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选路径)映射所有包,取代逐包条目。根 `tsconfig.json` 复用该源码映射,并携带显式的 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选包含 `/*/`,朴素的正则注释剥离器会误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手工剥离注释。) -- `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导出列表,解决了 `TODO(package-inventory)`。 -- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest 生成这些引用留作后续工作(见 [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md))。 +- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选)映射所有包,取代了逐包条目。根 `tsconfig.json` 复用该源映射,并携带显式 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选中包含 `/*/`,朴素的正则注释剥离器会将其误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手动剥离注释。) +- `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导列表,解决了 `TODO(package-inventory)`。 +- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest(元数据清单)生成这些引用留作后续工作(见 [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md))。 ### 新增的护栏 -两道 doc-sync/hygiene 门禁保证结构及其引用的正确性,使本次重组所需的人工检查不必再次手动重复: +两道 doc-sync/hygiene 门禁确保结构及其引用保持正确,使本次重组所需的手动检查无需日后重复: -- `scripts/verify-package-paths.ts` 标记 Markdown 或 `.ts` 注释/字符串中的 `packages/<path>` 引用:如果该路径无法解析**且**某段命名了一个真实存在的包,则视为指向已移动包的陈旧路径。如果路径命名的包在任何地方都不存在(前瞻性提案),则不报错;因此该门禁对 proposed/implemented/rejected 统一适用。 -- `scripts/check-workspace-constraints.ts` 断言 `packages/<group>/<pkg>` 形状:分组目录不含 `package.json`,没有包扁平地位于根层级或嵌套更深。分组名称保持开放——新增分组无需修改门禁;只有深度 2 的形状是固定的。 +- `scripts/verify-package-paths.ts` 标记 Markdown 或 `.ts` 注释/字符串中的 `packages/<path>` 引用,如果该引用无法解析**且**某个路径段命名了一个真实存在的包,即指向已移动包的陈旧路径。如果路径命名的包在任何地方都不存在(前瞻性提案),则不予标记,因此该门禁在 proposed/implemented/rejected 中统一适用。 +- `scripts/check-workspace-constraints.ts` 断言 `packages/<group>/<pkg>` 形状:分组目录不带 `package.json`,且没有包扁平地位于根层或嵌套更深。分组名称保持开放——添加新分组无需修改门禁;只有深度 2 的形状是固定的。 ## 曾考虑的替代方案 -- **第三层(每个族下设 `adapters/`/`impls/`)**:否决。统一深度 2 使 workspace glob 保持简洁的 `packages/*/*`,一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包。 +- **第三层(每个族下设 `adapters/`/`impls/`)**:否决。统一深度 2 使 workspace glob 保持简洁的 `packages/*/*`,并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包。 - **将持久化嵌套在 `core/session/` 下**:否决。存储后端构成一个平行的能力族,与 `llm/` 和 `bash/` 对称,而会话日志本身属于核心产品 API。 -- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口面;`acp` 是 `ui/` 的唯一成员,因为编辑器确实在驱动它。 +- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口;`acp` 是 `ui/` 的唯一成员,因为编辑器实际驱动它。 ## 后果 -本次重组在一次协调的变更中搅动了 import、workspace glob、文档链接、构建引用和包路径。这种搅动在发布前是可接受的(遵循 AGENTS.md 中「基础优先于爆炸半径」的立场),因为它阻止了扁平布局将支撑包固化为产品契约;而且这是一次性成本:通配符 `paths`、glob 推导的 publint 列表和形状门禁意味着新增一个包无需再做额外的结构编辑。 +本次重组在一次协调的变更中搅动了 import、workspace glob、文档链接、构建引用和包路径。这种变动在发布前是可接受的(依据 AGENTS.md 中「基础优先于爆炸半径」的立场),因为它阻止了扁平布局将支撑包固化为产品契约,且这是一次性成本:通配符 `paths`、glob 推导的 publint 列表和形状门禁意味着新增一个包无需额外的结构性编辑。 diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index c69c49d2c7..4a0b9d6ebc 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-21-mandatory-app-attribution-headers.md: 4fa773e089b3a4b682e42269a66d85aeaf5c18f6 -2026-06-21-mandatory-app-attribution-headers.zh.md: 3660b8e7de977c01a19f9ed9ac9e73409f69706a +2026-06-21-mandatory-app-attribution-headers.zh.md: 42cc396b5719adb2a2d71e0e9cf0d3554c5533a5 diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 3660b8e7de..42cc396b57 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -1,84 +1,84 @@ # RFC:对提供方请求强制携带 `User-Agent` 归属标识 -Status: implemented - [English](2026-06-21-mandatory-app-attribution-headers.md) | 中文 +Status: implemented + ## 问题 -LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只部分做到了这一点:手写的 DeepSeek 适配器发送一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以静默地遗漏归属标识,而库封装的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 +LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 -直接触发点来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 通过 `HTTP-Referer` 加展示名/分类头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的确切头部集合当作通用标准采纳,然后将提供方特定的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 +直接触发因素来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 ## 调研 -- **OpenRouter 的机制是提供方特定的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title` 和 `X-OpenRouter-Categories` 追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。 -- **在 agent 工具领域,`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACP(Agent Client Protocol)等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。 -- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特定附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。 -- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件的身份标识,说明它用于互操作性报告和分析,并说用户代理应当(SHOULD)在每个请求中发送它,除非被配置为不发送。这是唯一直接匹配「哪个产品在发出这个 HTTP 请求」的标准头部。 +- **OpenRouter 的机制是提供方特有的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title` 和 `X-OpenRouter-Categories` 来追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。 +- **在 agent 工具生态中,`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACP(Agent Client Protocol)等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界中被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。 +- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特有的附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。 +- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件身份,说明它用于互操作性报告和分析,并说用户代理*应当*在每个请求中发送它(除非被配置为不发送)。这是唯一直接对应「哪个产品在发出此 HTTP 请求」的标准头部。 - **`Referer` 是标准的,但 OpenRouter 的 `HTTP-Referer` 不是标准字段。** RFC 9110 第 10.1.3 节将 `Referer` 定义为获取目标 URI 的来源 URI,并用大量篇幅讨论隐私限制。OpenRouter 则要求 `HTTP-Referer`,将其用作应用 URL 标识符。该名称和含义是 OpenRouter 特有的,尽管它形似标准 `Referer` 头部的 CGI 环境变量形式。 -- **`From` 是标准的,但不适合作为强制默认。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人类的电子邮件地址。机器人代理应当(SHOULD)发送它以便服务器联系运营者,但非机器人代理不应在没有用户显式配置的情况下发送它,因为存在隐私和安全策略顾虑。harness 可以后续支持运营者联系方式,但不得凭空编造或全局强制要求。 -- **请求体中的 `user` 或 `metadata` 字段不是应用归属。** 某些模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特定的 body schema,要么不保证能通过 OpenAI 兼容网关转发。它们不能替代静态的应用身份头部。 -- **SDK 遥测头部标识的是 SDK,而非应用。** 官方和第三方 SDK 经常发送库/版本头部。这些帮助 SDK 维护者调试客户端,但除非应用显式提供产品归属层,否则它们不会将 harness 标识为应用。 -- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai` 的 `StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此库封装的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式(wire format)契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。 +- **`From` 是标准的,但不适合作为强制默认值。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人的电子邮件地址。机器人代理*应当*发送它以便服务器联系运营者,但非机器人代理出于隐私和安全策略考虑不应在未经用户显式配置的情况下发送。harness 可以后续支持运营者联系方式,但不得凭空捏造或全局强制要求。 +- **请求体中的 `user` 或 `metadata` 字段不是应用归属。** 部分模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特有的 body schema,要么不保证能通过 OpenAI 兼容网关透传。它们不能替代静态的应用身份头部。 +- **SDK 遥测头部标识的是 SDK,而非应用。** 官方和第三方 SDK 常发送库/版本头部。这些帮助 SDK 维护者调试其客户端,但除非应用显式提供产品归属层,否则它们不能标识 harness 作为应用。 +- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai` 的 `StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此基于库的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。 ## 决策 -在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则是:每个产品 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于库封装的适配器,库的头部钩子喂入同一个 mock 服务器断言)。 +在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个生产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。 -本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特定的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,带有自己的隐私/产品决策、测试和文档。在那之前,即使请求指向 OpenRouter,也只发送本 RFC 的共享 `User-Agent` 归属。 +本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本 RFC 定义的共享 `User-Agent` 归属。 -提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各个适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值: +提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值: -- `User-Agent` 的产品令牌:`deepseek-harness`(与 RFC 之前的线路值以及仓库/组织身份保持连续性) -- 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 阻塞发布,直到该仓库实际存在 +- `User-Agent` 的产品 token:`deepseek-harness`(与 RFC 之前的线路值及仓库/组织身份保持连续性) +- 版本:通过 `createRequire` 从所属包的 manifest 读取,绝不手动复制常量 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 -默认值是强制的且非空。白标部署向 `attributionHeaders(identity)` 传入自己的 `AppIdentity`——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 让模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 +默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 -线路映射(`attributionHeaders`;代码中头部名称为小写——HTTP 字段名在线路上不区分大小写): +线路映射(`attributionHeaders`;代码中头部名称小写——HTTP 字段名在线路上不区分大小写): | 目标 | 映射 | |---|---| | 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | -| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档记录了等效契约,否则不发送 OpenRouter 专用头部。 | +| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档化了等效契约,否则不发送 OpenRouter 特有头部。 | | OpenRouter 端点 | 目前仅 `User-Agent`。本 RFC 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | -| 未来提供方 | 仅 `User-Agent`,除非后续提供方特定 RFC 接受额外头部。不以类推方式复用 `HTTP-Referer`。 | +| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 RFC 接受额外头部。不要类比复用 `HTTP-Referer`。 | -端点检测不属于本 RFC,因为此处不接受任何端点特定映射。如果后续落地 OpenRouter 支持,检测必须是显式的:要么是专用的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名。 +端点检测不在本 RFC 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。 ## 验证 已落地的契约: -- `dsh-llm` 为 `LlmAdapter` 作者记录了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README,以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约章节)。 +- `dsh-llm` 为 `LlmAdapter` 作者文档化了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README,以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约章节)。 - 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。 - `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。 - `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。 -- 本 RFC 下没有适配器发送 OpenRouter 特定的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 -- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户稳定标识符。 -- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现行为。 +- 本 RFC 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 +- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户的稳定标识符。 +- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现的行为。 ## 曾考虑的替代方案 -**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特定的产品功能,不是本 RFC 试图标准化的提供方无关模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在第一个共享归属辅助函数中。 +**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 RFC 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。 -**所有地方都发 OpenRouter 头部。** 否决。这会把一份自定义 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的字段。还有风险把 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。 +**向所有提供方发送 OpenRouter 头部。** 否决。这会把一份自定义的 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险将 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。 -**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/分类,不帮助 OpenRouter 等网关构建应用排名。 +**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/类别,无法帮助 OpenRouter 等网关构建应用排名。 -**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态产品身份,且可安全地在每个请求上发送。 +**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态的产品身份,且可安全地在每个请求上发送。 -**仅配置 opt-in 的归属。** 否决。默认关闭的设置正是适配器持续漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。 +**仅配置启用的归属。** 否决。默认关闭的设置正是适配器不断漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。 -**以产品命名的令牌(`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` 令牌,因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 以连续性胜出:它是提供方已经从本代码库看到的身份,与组织/仓库身份和包作用域一致,且在展示文案承载产品名的同时保持线路归属稳定。 +**以产品命名的 token(`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` token,因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 因连续性胜出:它是提供方从本代码库已经看到的身份,与组织/仓库身份和包 scope 一致,且在展示文案承载产品名的同时保持线路归属稳定。 ## 后果 -**提供方看到流量来自 harness。** 这正是目的,但意味着此前混入通用 SDK 流量的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 +**提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在创建之前该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,使其不会在未解决的情况下发版(见 `docs/development.md` 标记语义)。 +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。 -**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;pi-ai 封装的适配器依赖 pi-ai 继续遵守 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件变红。这对抽象层是有益的压力:一个无法设置强制头部的提供方适配器无法完整实现 harness 的 LLM 契约。 +**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。 -**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 才能实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。 +**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关的 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 来实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。 diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index 7a69d33245..06f5fb57d8 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-26-file-context-as-event-gate.md: 6e78e2df5f7969b5ed9b74c0b597e2fcacbe8e82 -2026-06-26-file-context-as-event-gate.zh.md: b0806fd3e1d61a9bdaf20a728dbfcfc945013b78 +2026-06-26-file-context-as-event-gate.zh.md: d69ccdbcea4b14dbd0291cf69af0bf7d5f3fadfc diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index b0806fd3e1..d69ccdbcea 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -1,4 +1,4 @@ -# RFC:将 `dsh-fs-policy` 改为事件门禁插件,而非方法接口 +# RFC:将 `dsh-fs-policy` 改为事件门控插件,而非方法接口 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 都路由到它的方法。这使得 `fileContext` **处于调用路径上且不可省略**。工具不经过它就无法触及 `ctx.fs`,策略层拥有 fs I/O 和读取窗口化,而一个不需要观测状态策略的部署无法简单地移除该包——否则 `dsh-tool-fs` 将无法解析 `ctx.fileContext`。 +[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 这把三件本应可分离的事情耦合在了一起: @@ -14,11 +14,11 @@ Status: implemented 2. **新鲜度/观测策略**——"编辑前必须先读"、"写入/编辑必须基于你读到的版本"。这是 `dsh-fs-policy` 插件的职责。 3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。 -因为工具调用 `fileContext` 的方法,移除策略层是一个破坏性变更,而非优雅地失去一个*附加功能*。策略对于工具的运行是承重的,而非可选的收紧。 +由于工具调用的是 `fileContext` 方法,移除策略层就是一个破坏性变更,而非优雅地失去一个*附加*能力。策略层对工具的运行是承重性的,而非可选的收紧。 ## 决策 -反转控制流。**`dsh-tool-fs` 成为执行器,直接调用 `ctx.fs`**;**`dsh-fs-policy` 成为门禁 + 记录器插件**,通过事件参与,既不通过工具调用的方法,也不注册 `ctx.fileContext` 服务。 +反转控制流。**`dsh-tool-fs` 成为执行器,直接调用 `ctx.fs`**;**`dsh-fs-policy` 成为门控 + 记录插件**,通过事件参与,从不通过工具调用的方法,也不注册 `ctx.fileContext` 服务。 ```text tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs; @@ -31,22 +31,22 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who provider dsh-fs-local local implementation of ctx.fs ``` -该模型是叠加式的:裸 `ctx.fs` 执行原子的、无约束的文本 I/O,而 `dsh-fs-policy` 在其上叠加观测状态、读后才能编辑、以及版本守卫。因此移除策略后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了在服务边界保持策略可选,而非作为正常部署姿态。 +该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O,而 `dsh-fs-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。 -`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 以及 `tools`/`systemPrompt`。 +`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 和 `tools`/`systemPrompt`。 -## 策略由提供方 CAS 强制执行,而非由 `dsh-fs-policy` stat +## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat -`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的变更临界区检测陈旧: +`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性: - "你读过这个文件吗?"是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。 -- "你读到的版本还是最新的吗?"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一把原子锁中。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。 +- "你读到的版本是否仍为最新?"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。 -这是刻意的设计。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,那么该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在两者之间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区内既无竞态又零额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;"必须基于最新读取"的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。 +这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;"必须基于最近一次读取"的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。 ## 提供方契约变更:版本守卫变为可选 -为使裸提供方不受约束,其两个变更操作上的版本守卫变为**可选**——有则守卫,无则无条件: +为使裸提供方不受约束,其两个 mutation 上的版本守卫变为**可选**——传入则守卫,省略则无条件执行: ```ts ignore-check // writeText: expected is now optional. The FsWriteIntent union is UNCHANGED. @@ -62,17 +62,17 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion // { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) ``` -`FsWriteIntent` 联合类型本身不变——第三种"无条件"状态通过*省略* `expected` 来表达,因此两个变更操作共享一个对称的形状(`expected?`:省略 = 无守卫,提供 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能的"无守卫"情况是新增的,且它是裸提供方的默认行为。无论哪种情况,变更操作仍在后端的 per-target 锁内运行,因此无条件的写入/编辑仍然是原子的(不会出现文件撕裂);"无条件"去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失的目标报告为 `FS_STALE_VERSION`,为"此刻无法编辑该目标"保留一个统一的编辑失败码。 +`FsWriteIntent` 联合类型本身不变——第三种"无条件"状态通过*省略* `expected` 来表达,因此两个 mutation 共享同一种对称形状(`expected?`:省略 = 无守卫,传入 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能出现的"无守卫"情况是新增的,且它是裸提供方的默认行为。无论哪种情况,mutation 仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会产生撕裂文件);"无条件"去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失目标报告为 `FS_STALE_VERSION`,保持一个统一的编辑失败码表示"此刻无法编辑该目标"。 -## 事件词汇(归属 `dsh-fs`) +## 事件词汇(由 `dsh-fs` 拥有) -事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦契约所要求的:`dsh-tool-fs` 是事件发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。 +事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦契约所迫:`dsh-tool-fs` 是发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。 -这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加上一个不透明的 actor——而非面向模型的概念(行窗口、行号、渲染页脚均不会泄漏到此层)。 +这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念(行窗口、行号或渲染后的页脚不会泄漏到此层)。 -**两个 `fs/*` 决策事件是单槽位、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 即返回,因此在默认部署中它占据该槽位;一个注册更早或使用 `prepend` 的监听器会取代该策略。权限、审计和沙箱关注点仍在可组合的 `tools/execute` waterfall 上。 +**两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 `prepend` 的监听器会替代该策略。权限、审计和沙箱关注点仍留在可组合的 `tools/execute` waterfall 上。 -actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或窄化它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其监听器将 `object` actor 窄化为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它**不**拥有策略层的运行时 owner 结构。 +actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或收窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其在监听器中将 `object` actor 收窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它**不**拥有策略层的运行时 owner 结构。 ```ts import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' @@ -106,66 +106,66 @@ interface Events { } ``` -`fs/*` 决策事件是**由工具分发的无绑定 waterfall**(类似 `agent/request`,由 loop 分发且无 `this`),而非服务绑定的 waterfall(如 `llm/stream`)。分发方是 `dsh-tool-fs` 插件,它不是一个服务。 +`fs/*` 决策事件是**由工具分发的无绑定 waterfall**(类似 `agent/request`,由循环分发且无 `this`),而非服务绑定的 waterfall(如 `llm/stream`)。分发者是 `dsh-tool-fs` 插件,它不是一个服务。 ## 工具契约(`dsh-tool-fs`) -工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略为先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何说"后端"要求如此的措辞应改为说 fs-policy 插件要求如此。裸提供方的回退不改变 prompt 立场。 +工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称"后端"要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。 -`dsh-tool-fs` 获得了从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为工具拥有了读取操作。这些读取渲染类型和辅助函数迁入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。 +`dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。 -`dsh-tool-fs` 是一个注册全部三个工具(`read`/`write`/`edit`)的单根插件,与 `dsh-tool-bash` 对齐。它注入 `fs`(加 `tools`/`systemPrompt`),从不注入 `fileContext`。(最初的提案还将每个工具作为 `/read`/`/write`/`/edit` 子路径插件暴露,以支持聚焦部署;实现时已放弃——没有消费方需要单工具部署,且子路径发布迫使引入定制的 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理,而同级的工具包都不需要这些。每工具的注册辅助函数(`applyReadTool`/`applyWriteTool`/`applyEditTool`)保留为根插件组合的内部模块。) +`dsh-tool-fs` 是一个注册全部三个工具(`read`/`write`/`edit`)的单一根插件,与 `dsh-tool-bash` 相同。它注入 `fs`(加 `tools`/`systemPrompt`),从不注入 `fileContext`。(最初的提案还将每个工具作为 `/read`/`/write`/`/edit` 子路径插件暴露,供聚焦部署使用;实现时被放弃——没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理。每工具的注册辅助函数(`applyReadTool`/`applyWriteTool`/`applyEditTool`)仍作为根插件组合的内部模块保留。) -`stat` 预算通过让 waterfall 惰性产出期望值来最小化——裸默认返回 `undefined`(无守卫),从不 stat: +通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat: -- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读取后的确认 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑虚假地 `FS_STALE_VERSION`(快速失败:模型重新读取,从不基于错误版本写入,因为 `editText` 在其锁内重新检查)。 -- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。**工具内零 stat**,无论是否有 `dsh-fs-policy`。 -- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。**两种情况下工具内均零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。 +- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(快速失败:模型重新读取,从不基于错误版本写入,因为 `editText` 在其锁内重新检查)。 +- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`,**工具内零 stat**。 +- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。 -工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,这样 `dsh-fs-policy` 就能推导其观测状态的 owner。工具不知道策略插件是否存在:它总是在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。 +工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,以便 `dsh-fs-policy` 推导其观测状态的 owner。工具不知道策略插件是否存在:它始终在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。 -**`fs/observed` 在操作成功后触发。** 其监听器必须是同步的、不抛异常的记录器;工具不对 plain emit 做守卫,因此抛异常的监听器会在变更已成功后报告失败。异步或可失败的观测需要另一个事件契约。 +**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器会在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件契约。 ## 策略插件契约(`dsh-fs-policy`) -`dsh-fs-policy` 是一个插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,也不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个用于 HMR(热模块替换)的 disposer(资源释放))。它维护观测状态的 `WeakMap<owner, Map<targetKey, { version }>>` 和结构化的 owner 推导(将事件中不透明的 `object` actor 窄化为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。 +`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR)。它维护观测状态 `WeakMap<owner, Map<targetKey, { version }>>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。 - `fs/write-intent` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。 - `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。 - `fs/observed` 监听器:`record(owner, key, version)`。 -一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着"该 owner 在此版本观测过该目标",而非狭义的"已读取过"。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:变更操作将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose(资源释放)时丢弃所有状态(HMR 安全)。 +一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着"此 owner 在此版本观测过此目标",而非狭义的"已读取过"。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。 -`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外部世界。这正是从 `dsh-tool-fs` 移除方法耦合的关键。 +`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。 ## 裸提供方行为(无 `dsh-fs-policy`) -这不是预期的部署姿态——加载 fs 工具的配置预期也会加载 `dsh-fs-policy`。这是工具不再耦合于策略方法服务后存在的无约束提供方下限。在 `dsh-fs-policy` 缺席时,每个 `fs/*` waterfall 都落入其 `undefined` 默认值,`fs/observed` 无监听器: +这不是预期的部署姿态——加载 fs 工具的配置预期也会加载 `dsh-fs-policy`。它是工具不再耦合于策略方法服务后所存在的无约束提供方下限。当 `dsh-fs-policy` 不存在时,每个 `fs/*` waterfall 落入其 `undefined` 默认值,`fs/observed` 无监听器: -- **read** 不变(它从不需要策略;只是 emit 了一个现在无人听取的 `fs/observed`)。 -- **write** 无条件 create-or-overwrite:`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无读取前置要求,无版本检查。 -- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 不带版本守卫或读取前置要求即进行匹配和重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍然适用——它们关乎字面匹配,而非新鲜度)。缺失的目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。 +- **read** 行为不变(它从不需要策略;只是 emit 了一个现在无人监听的 `fs/observed`)。 +- **write** 无条件 create-or-overwrite:`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无先读要求,无版本检查。 +- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 无版本守卫、无先读要求地匹配并重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。 -两个变更操作仍然是原子的(后端的 per-target 锁是无条件的)。简单地*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、读后才能编辑、以及版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身不变。 +两个 mutation 仍是原子的(后端的 per-target 锁是无条件的)。仅仅是*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、先读后编辑和版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身无需任何变更。 -## 取代 +## 取代关系 -本 RFC 修正——而非撤销——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。改变的是**工具与策略层之间的耦合方式**:一个强制方法服务变成了插件拥有的事件门禁,fs I/O + 读取窗口化从 `fileContext` 上移到了 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 +本 RFC 修正——而非推翻——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 ## 验证 -测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读取的 edit 均成功;有策略时,未读取的 edit 返回 `FS_NOT_OBSERVED`,未读取的 overwrite 被 `createIfAbsent` 门控。策略做出决策后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具的预算在两条路径上均为 read 一次 `stat`、write 或 edit 零次 `stat`。面向模型的 schema 逐字节不变,因此快照不变。 +测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`、write 或 edit 零次 `stat`。面向模型的 schema 逐字节不变,因此快照不变。 ## 曾考虑的替代方案 -- **保留 `ctx.fileContext` 作为路径内方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具不加载策略层就无法运行,使策略对基本操作是承重的,而非可选的收紧。 -- **策略侧版本检查**(`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的变更临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 -- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃。没有消费方需要单工具部署,且子路径发布迫使引入定制的 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理,而同级的工具包都不需要这些;每工具的注册辅助函数保留为根插件组合的内部模块。 +- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 +- **策略侧版本检查**(`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 +- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。 ## 后果 -- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具对策略的方法依赖,同时保留默认策略插件;代价是多了一套事件词汇需要学习。通过将三个事件保持窄小并在每个事件上记录 default-thunk 语义来缓解。 -- **策略事件放在存储 seam 中。** `dsh-fs` 获得了两个版本决策事件加一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,因此 seam 不会沾染行窗口/观测策略类型和 agent/session owner 结构。 -- **单策略占位,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend` 的)监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能特性。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。 -- **移除读取后的确认 stat** 使后续*有守卫*的编辑在读写竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,从不是正确性漏洞;提供方锁仍然阻止基于错误版本的写入。 -- **裸提供方不做读后写入/编辑检查,也不做版本检查。** 不加载 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何现有文件。这正是保持工具独立于策略服务的刻意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;这不是发布 fs 工具的配置的预期姿态。 +- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。 +- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。 +- **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。 +- **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。 +- **裸提供方不做先读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。 diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 041271d286..3bb8d3e607 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 72aae03361cbc088cf64f3548a43ac6253eb21eb -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 5c708cb6bfed28b2164cbd1d0b1c7368bf3e1d07 +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: f661199048b7eaa359f792e96ac52baf8cd61fdf diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 5c708cb6bf..f661199048 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -1,4 +1,4 @@ -# RFC:bash seam 上的 stdin 与额外 env +# RFC:在 bash seam 上支持 stdin 与额外 env Status: implemented @@ -6,28 +6,28 @@ Status: implemented ## 问题 -钩子子系统运行外部钩子命令的方式与 Claude Code 和 Codex 相同:一个钩子就是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 在 `ctx.bash` 能力 seam 背后已经有一个完善的命令运行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组 kill、输出截断/溢出处理和凭证擦除。将它复用于钩子执行,意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前没有写入 stdin 或设置额外 env 的能力。本 RFC 添加这两项输入。 +钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 RFC 添加这两个输入。 -`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供这两者。环境中的凭证由 `dsh-bash-local` 的子进程环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。 +`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。 ## 决策 -在 `BashExecRequest`(面向模型/插件的请求)和 `BashExecSpec`(`run`/`start` 实际执行的解析后规格)上**同时**添加 `stdin?: string` 与 `env?: Record<string, string>`,并在 `dsh-bash-local` 中贯穿:`resolve()` 原样传递,`run()`/`start()` 将它们传给 `runBash`,后者把字节写入子进程的 stdin 并合并额外 env。 +在 `BashExecRequest`(模型/插件侧请求)和 `BashExecSpec`(`run`/`start` 所作用的已解析 spec)上**同时**添加 `stdin?: string` 与 `env?: Record<string, string>`,并在 `dsh-bash-local` 中贯穿它们:`resolve()` 原样传递,`run()`/`start()` 将其传给 `runBash`,后者把字节写入子进程的 stdin 并合并额外 env。 -三个刻意的选择: +三个有意为之的选择: -1. **面向模型的工具不暴露 `stdin` 和 `env`。** Shell 语法已经覆盖这些需求,重复的参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。 +1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使名称看起来像凭证。这是正确的,因为擦除的职责很窄:阻止 harness 自身 *ambient* `process.env` 中的凭证泄漏到子命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非 ambient 密钥),因此擦除不是对它的约束。`childEnv(extra?)` 的分层为 `scrub(process.env)` → `ENV_OVERRIDES`(面向模型的 `TERM=dumb` 等)→ `extra`,后者优先。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使键名与凭证同形。这是正确的,因为擦除的职责很窄:阻止 harness 的*环境* `process.env` 凭证泄漏到被 spawn 的命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非环境中的秘密),因此擦除不构成对它的约束。`childEnv(extra?)` 按 `scrub(process.env)` → `ENV_OVERRIDES`(对模型友好的 `TERM=dumb` 等)→ `extra` 的顺序分层,后者优先。 -3. **`stdin`/`env` 在解析后规格上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主的、跨会话可读的任务——这是一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 +3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 -`dsh-bash-local` 仅在提供了字节时才创建 stdin 管道;否则 fd 0 保持 `/dev/null`,维持原有行为。它写入字节后关闭管道。如果子进程未读取就退出导致 `EPIPE`,则忽略该错误,因为命令退出状态和输出决定结果。 +`dsh-bash-local` 仅在有字节需要写入时才创建 stdin 管道;否则 fd 0 仍为 `/dev/null`,保持先前行为。它写入字节后关闭管道。子进程未读取即退出时产生的 `EPIPE` 被忽略,因为命令退出码和输出决定结果。 ## 曾考虑的替代方案 -**可配置的 ambient 密钥擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的 ambient 保护。 +**可配置的环境秘密擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的环境保护。 ## 后果 -钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子专属变量,保留其进程组管理、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一入口。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。 +钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。 diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 557ac2bab4..2416919ab7 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-event-domain-semantics.md: e05c238c52052454d3e01e82767cddd9af316a9d -2026-06-30-event-domain-semantics.zh.md: a5453824183aa3f71486b5dbd24ed8c056d9c854 +2026-06-30-event-domain-semantics.zh.md: 9048679ec7a7992852cce76bf43269f5499da792 diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index a545382418..9048679ec7 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -1,4 +1,4 @@ -# RFC:事件域语义——session 是事实日志,agent 是实时表面 +# RFC:事件域语义——session 是事实日志,agent 是运行时表面 Status: implemented @@ -6,34 +6,34 @@ Status: implemented ## 问题 -harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着分类体系的增长,三个事件域之间的界限变得模糊: +harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: - `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。 -- `agent/*` 承载实时运行时信号,向插件传递 `Agent` 句柄。 +- `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 - `tools/*` 承载工具注册表与执行 seam。 -两个问题促使我们明确固定这些语义。第一,若干轮次/步骤边界同时以持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)和镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)两种形式存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要一个统一、有文档的订阅表面:插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须无需阅读循环代码就能判断应该监听会话事件还是 agent 事件,以及为什么。 +两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)**和**镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要**一个**连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听 session 事件还是 agent 事件,以及原因。 -这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code 与 Codex 桥接的基础。 +这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code 和 Codex 桥接的基础。 ## 决策 -**三个域,各司其职,一条边界规则。** +**三个域,各司其职,以一条边界规则统一。** -- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)流:想要渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。 -- **`agent/*`——实时运行时表面。** 始终携带活的 `Agent`。两种形态:拦截型 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可修改或否决,以及瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤的**边界**不在此域——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途引导(`steering/message`)同理。 +- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。 +- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤**边界**不在此处——它们是持久的 session 事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。 - **`tools/*`——工具注册表与执行 seam。** -**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中、从 `session/event` 流读取——不会被镜像为 `agent/*` emit。 +**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 -**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处持有活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除(它们根本没有消费方);turn 镜像在 ui-stdio 迁移后随之移除——见[移除边界镜像事件 RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它拥有。移除这些 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 +**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除(它们完全没有消费方);turn 镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 ## 后果 -- 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 的隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;acceptance 或内部校验失败仍会在边界进入日志之前逃逸。 -- 之前通过已移除 emit 观察边界的测试现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们所固定的行为(边界排序、步骤计数)不变;只是读取的流切换到了权威的那一个。那些测试「抛出异常的 turn 边界 emit 监听器」的用例被删除,因为该代码路径已不存在(没有 emit 可供抛出)。按照 [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md),行为与其测试一起迁移(或一起消亡)。 -- 循环仅在 `append('step/start')` 返回后才标记步骤为已打开(`stepOpen = true`)。内部 dispatch 校验在日志推送前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确代表已提交的边界,该边界欠一个后续的 `step/end`。 -- 本 RFC 的完整实现是[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 +- 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。 +- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试「抛出异常的 turn 边界 emit 监听器」的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 +- 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 +- 完整实现见[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 - Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml index fc9d825dc0..3cff8c7aca 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-fs-per-session-cwd.md: 00643955d918dff87241f4b240bb7e6774d21a0b -2026-07-02-fs-per-session-cwd.zh.md: ca37e1f41af53c43151ac82e1be1b76eaafdb97e +2026-07-02-fs-per-session-cwd.zh.md: 73176cde3747a2eb8c03aadbf3f419bf27173d70 diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md index ca37e1f41a..73176cde37 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md @@ -1,34 +1,34 @@ -# RFC:将文件系统路径解析基于调用方的会话 cwd - -Status: implemented +# RFC:相对文件系统路径按调用方的会话 cwd 解析 [English](2026-07-02-fs-per-session-cwd.md) | 中文 +Status: implemented + ## 问题 -ACP 桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent 的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 相关工作,以及 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录运行,会话 B 中的在 B 的项目目录运行——一个服务器进程,N 个工作区。 +ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。 -文件系统路径解析使用的是插件加载时的单一 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。 +文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。 ## 决策 -将调用方的会话 cwd 透传到路径解析中,与 `dsh-tool-bash` 对 `workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。 +将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash` 对 `workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。 -- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`。`opts.cwd` 是相对 `path` 的解析基准;绝对 `path` 忽略它;省略 `opts.cwd` 时使用后端自身的默认值。使用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。 -- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`。`config.cwd` 仍是调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。 -- `dsh-tool-fs` 的 `read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数获取会话 cwd(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 一致),并传给 `resolve`。非 agent/无 header 的调用方返回 `undefined`,后端则应用其默认值。 +- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`。`opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 则使用后端自身的默认值。采用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。 +- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`。`config.cwd` 仍作为调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。 +- `dsh-tool-fs` 的 `read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd,并传给 `resolve`。非 agent/无 header 的调用方得到 `undefined`,后端因此应用其默认值。 ## 曾考虑的替代方案 -### 为什么由调用方提供 cwd(而非提供方) +### 为何由调用方(而非提供方)提供 cwd -提供方 seam 不应依赖 `dsh-agent`/`dsh-session`:它是一个文本存储后端,沙箱或远程实现同样满足该接口,而它们没有「agent 会话」的概念。工具已经接收到 `ToolExecution`(`exec`),其中携带了 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包边界处显式优于隐式」的约定:基目录作为显式参数到达提供方并由其执行,而非让提供方越界去读取它不应知道的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。 +提供方 seam 不得依赖 `dsh-agent`/`dsh-session`——它是一个文本存储后端,沙箱或远程实现同样满足该接口,而这些实现没有「agent 会话」的概念。工具已经接收了 `ToolExecution`(`exec`),其中携带 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包(package)边界处显式优于隐式」的约定:基准目录作为显式参数传入,提供方据此行动,而非让提供方越界去读取它不应知晓的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。 -默认值只存在于**一个**地方:提供方的 `config.cwd`。`sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会制造一个提供方本来会自行选择的基目录。 +默认值只存在于**一个**地方——提供方的 `config.cwd`。`sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会自行制造一个提供方本应自行选择的基准目录。 ## 后果 -- 在 ACP 演示中,fs 工具和 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目文件夹,两类工具都在该目录下工作。 -- `FsTarget` 的标识不变:`targetKey` 仍然是解析后绝对路径的 realpath,因此 observed-state 键控和符号链接标识不受影响——正确的 per-session cwd 产生的 key 与 bash 目标一致。 +- 在 ACP 演示中,fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。 +- `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的 per-session cwd 产生与 bash 目标相同的 key。 - 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。 - 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。 diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml index b5dcdf42f8..0401c48e08 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-result-time-applied-hunk-diffs.md: 81ab8b9827ddaec39d63e2a3f8fbb864085a9ac9 -2026-07-02-result-time-applied-hunk-diffs.zh.md: 3914bb872025d7116a047dfaf3455f527e35879a +2026-07-02-result-time-applied-hunk-diffs.zh.md: 2914c4242c4246ede8588967ed36b3f6c725c607 diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md index 3914bb8720..2914c4242c 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md @@ -1,20 +1,20 @@ # RFC:结果时刻的 applied-hunk diff 用于文件变更 -Status: implemented - [English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文 +Status: implemented + ## 问题 -[带标签的渲染意图联合类型](2026-07-02-tool-render-intent-union.md)为 `dsh-tool-fs` 的 write/edit 在调用时(CALL time)提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次 `replace_all` 如果触及五个分散位置,仍然只渲染为一对片段。 +[tagged render-intent union](2026-07-02-tool-render-intent-union.md) 为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。 -驱动 `claude-agent-acp` 自身的 ACP 桥接层可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原地*展示变更(而非浮动片段)的关键。我们的工具止步于调用时片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。 +在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中**原位**显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。 -障碍在于一个 seam 边界:`presentResult(args, result)` 是 **`args` + 面向模型的 `result`(`{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放时都会运行,因此必须具有回放确定性且不能做 I/O。它看不到文件的变更前/后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数 + 版本,没有文本。因此既无法计算、也无法传递 applied hunk 给 presenter。 +障碍在于一个 seam 边界:`presentResult(args, result)` 是 **`args` + 面向模型的 `result`(`{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数和版本号,没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。 ## 决策 -新增一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来承载 applied-hunk diff。 +添加一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来携带 applied-hunk diff。 ### 1. 工具结果上的 `meta` 通道(core) @@ -24,38 +24,38 @@ Status: implemented type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } ``` -`meta` 是工具自有的 `unknown`,core 持久化它但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷传回 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。 +`meta` 是工具自有的 `unknown`,core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回传给 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。 -这是通用形态("工具附加持久化的结果展示"),而非 fs 专用——任何工具都可以使用。 +这是通用形态("工具附加持久化的结果展示"),而非 fs 特有的——任何工具都可以使用。 -### 2. 工具计算 hunk;后端返回变更前/后文本(fs) +### 2. 工具计算 hunk;后端返回 before/after(fs) -按照[能力-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**: +按照 [capability-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**: -- `dsh-fs` 扩展 `FsEditOutcome`,增加 `{ before: string; after: string }`;扩展 `FsWriteOutcome`,增加 `{ before: string | null; after: string }`(`before: null` ⇒ 新建文件,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。 -- `dsh-tool-fs` 将带上下文的 hunk 存入 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换 pending 卡片:新建或无变化的覆写回退为参数推导的全文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染错误信息。 +- `dsh-fs` 将 `FsEditOutcome` 扩展为包含 `{ before: string; after: string }`,将 `FsWriteOutcome` 扩展为包含 `{ before: string | null; after: string }`(`before: null` 表示创建,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。 +- `dsh-tool-fs` 将上下文 hunk 存入 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换待定卡片:创建或无变化的覆写回退到由参数推导的整文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。 -### 3. 桥接层渲染 `diff` 结果卡片 +### 3. Bridge 渲染 `diff` 结果卡片 -`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`;桥接层结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中**替换**调用时的内容,因此结果 diff **取代**调用时片段(并防止面向模型的结果文本覆盖它)——两次更新的序列(先调用片段,后结果 diff)与 `claude-agent-acp` 完全一致。 +`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`;bridge 结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中**替换**调用时的内容,因此结果 diff **取代**调用时刻的片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,再结果 diff)与 `claude-agent-acp` 完全一致。 ## 曾考虑的替代方案 -**手写或 vendor diff 算法。** 带上下文的 hunk 有已知的边界情况,因此 `dsh-tool-fs` 使用带类型的 [`diff`](https://www.npmjs.com/package/diff) 包,并在一个模块中规范化 `structuredPatch` 输出。本仓库的 vendor 策略适用于其框架源码,而非每个叶子工具。 +**手写或 vendor diff 算法。** 上下文 hunk 有已知的边界情况,因此 `dsh-tool-fs` 使用带类型的 [`diff`](https://www.npmjs.com/package/diff) 包,并在一个模块中规范化 `structuredPatch` 输出。仓库的 vendor 策略适用于框架源码,而非每个叶子工具库。 ## 后果 -`tool/result` 事件现在可以携带工具私有的 `meta` 载荷——属于磁盘词汇的一部分,由 `Session.append` 在运行时限制为 JSON——任何工具都可以附加持久化的结果展示而无需再改 core。diff 卡片在会话重载和快照回放时免费复现:从日志读回,从不重新计算。代价:覆写操作在内存中同时持有变更前和新文本以计算仅用于 UI 的 hunk(`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。 +`tool/result` 事件现在可以携带工具私有的 `meta` 载荷——属于磁盘格式词汇的一部分,由 `Session.append` 在运行时限制为 JSON——任何工具都可以附加持久化的结果展示而无需再改 core。diff 卡片在会话重载和快照回放时免费复现:它从日志中读回,从不重新计算。代价:覆写操作在内存中同时持有旧文本和新文本以计算仅用于 UI 的 hunk(`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。 ## 非目标 -- **实时增量 diff 流式输出。** hunk 在变更完成后一次性计算;没有逐按键 diff。 -- **对二进制/非 UTF-8 覆写做 diff。** 此类文件的 `before` 为 `null`(没有文本 diff 基础);写入仍然成功,结果渲染全文件 diff(`oldText: null`)而非带上下文的 hunk。 -- **重命名/移动 diff。** 仅对单个已解析路径做内容 diff。 -- **限制覆写 diff 基础的大小。** 覆写操作将整个旧文件读入内存以计算带上下文的 hunk(在已持有的新内容之上),因此非常大的文本覆写会为仅 UI 用途的 diff 分配两份文本。后续优化可以设定预读上限,超过阈值时回退到全文件/无上下文 diff;以 `TODO(overwrite-diff-bound)` 标记在读取位置。 +- **实时增量 diff 流式输出。** hunk 在变更完成后一次性计算;没有逐键 diff。 +- **对二进制/非 UTF-8 覆写做 diff。** 此类文件的 `before` 为 `null`(没有文本 diff 基础);写入仍然成功,结果渲染整文件 diff(`oldText: null`)而非上下文 hunk。 +- **重命名/移动 diff。** 仅限单个已解析路径的内容 diff。 +- **限制覆写 diff 基础的大小。** 覆写操作将整个旧文件读入内存以计算上下文 hunk(加上已持有的新内容),因此非常大的文本覆写会为仅 UI 用途的 diff 分配两份文本。未来的改进可以设定预读上限,超过阈值时回退到整文件/无上下文 diff;在读取位置以 `TODO(overwrite-diff-bound)` 跟踪。 ## 相关 -- 补齐了[带标签的渲染意图联合类型](2026-07-02-tool-render-intent-union.md)中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 -- 建立在[文件系统能力 seam](2026-06-17-filesystem-capability-seam.md)(变更前/后文本是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)(`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)之上。 -- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果等)可以附加自己的持久化结果展示而无需再改 core。 +- 补全了 [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) 中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 +- 基于[文件系统 capability seam](2026-06-17-filesystem-capability-seam.md)(before/after 是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)(`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)。 +- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。 diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 4c8a1f1b63..8c2f7faf6d 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-tool-render-intent-union.md: 8256f09f9c297658627d0c3d9e99ee1c5424b254 -2026-07-02-tool-render-intent-union.zh.md: ed46bf0a8bea1cbfbce4287e5dc48be21c8d8fb9 +2026-07-02-tool-render-intent-union.zh.md: 35bd775545c9131a20da9e7f7424506e4554eb4b diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index ed46bf0a8b..35bd775545 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -1,4 +1,4 @@ -# RFC:用于工具调用展示的标签化 render-intent 联合类型 +# RFC:用于工具调用展示的带标签 render-intent 联合类型 Status: implemented @@ -6,17 +6,17 @@ Status: implemented ## 问题 -工具通过 `ToolDefinition` 上的两个回调 `presentCall`/`presentResult` 声明其调用在 UI(编辑器的工具调用卡片)中的渲染方式,返回 `ToolCallPresentation` / `ToolResultPresentation`,并带有可选的 `ToolTerminal` 子结构。这些类型在增量演进中变成了一个**可选字段的大杂烩**:调用侧有 `title`、`kind`、`rawInput`、`content`、`locations`、`terminal`;结果侧有 `title`、`content`、`terminal`;`ToolTerminal` 上有 `cwd`/`output`/`exitCode`/`signal`。职责划分含混不清: +工具通过 `ToolDefinition` 上的两个回调 `presentCall`/`presentResult` 声明其调用在 UI(编辑器的工具调用卡片)中如何渲染,返回 `ToolCallPresentation` / `ToolResultPresentation`,并带有一个可选的 `ToolTerminal` 子结构。这些类型在增量演进中变成了一个**可选字段的集合**:调用侧有 `title`、`kind`、`rawInput`、`content`、`locations`、`terminal`;结果侧有 `title`、`content`、`terminal`;`ToolTerminal` 上有 `cwd`/`output`/`exitCode`/`signal`。职责划分模糊不清: -- 调用侧和结果侧的 `terminal` 字段重叠,bridge 需要将一个 `content` 块、一个 `terminal` 块和 `rawInput` 按调用拼接在一起,靠临时条件逻辑缝合。 -- 哪些组合是*合法的*没有文档:一个设置了 `terminal` 的调用如果同时设置了 `content`,含义是「卡片上方的描述」;一个 generic 调用如果设置了 `terminal`,毫无意义但类型允许。类型允许无意义的状态。 -- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 是 *LLM* 的 `ContentBlock[]` 词汇(text/image),工具字面上无法请求一个 diff。 +- 调用侧和结果侧的 `terminal` 字段重叠,bridge 需要将每次调用的 `content` 块、`terminal` 块和 `rawInput` 用临时条件逻辑拼接在一起。 +- 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 +- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中现有的 `FIXME(tool-presentation)` 指明了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的标签联合类型),而不是一堆可选字段由 bridge 拼接。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方来验证词汇之后,以标签化 render-intent 联合类型的形式回归」。这个门槛现已达到:两个生产方族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot-golden 回放路径)。 +`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot-golden 回放路径)。 ## 决策 -用一个**以 `card` 为标签的可辨识联合类型**替代可选字段大杂烩。工具为每次调用/结果声明一个渲染意图;bridge 按标签分发。 +用一个**以 `card` 为标签的可辨识联合类型**替代可选字段集合。工具为每次调用/结果声明一个渲染意图;bridge 根据标签分发。 ```ts ignore-check type FileLocation = { path: string; line?: number } @@ -34,41 +34,41 @@ interface GenericResultView { card: 'generic'; title?: string; content?: Content interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string } ``` -`card` 在每个变体上都是**必填**的:一个真正的判别字段,而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何都需要新的 bridge 代码来渲染,因此一个插件添加的变体如果被 bridge 静默丢弃,比编译错误更糟。添加变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。 +`card` 在每个变体上都是**必填**的——真正的判别式,而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何需要新的 bridge 代码来渲染,因此一个由插件添加但被 bridge 静默丢弃的变体,比编译错误更糟糕。新增变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。 -### 为什么标签联合类型优于字段大杂烩 +### 为什么带标签联合类型优于字段集合 -- **无效状态变得不可表示。** generic 卡片不能携带终端输出;terminal 卡片不能携带 diff。旧的大杂烩允许所有这些组合。 -- **bridge 按分支分发,而非拼接。** 每种卡片一个分支,各自精确产出该卡片所需的协议格式(wire format),而非协调五个交互关系未文档化的可选字段。 -- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明 `card:'diff'`;bridge 发出 ACP `{type:'diff', path, oldText, newText}` `ToolCallContent`(已存在于 SDK 的 `ToolCallContent` 联合类型中,此前 bridge 未使用)。这是本次重设计解锁的能力。 +- **无效状态变得不可表达。** generic 卡片不能携带终端输出;terminal 卡片不能携带 diff。旧的字段集合允许所有这些组合。 +- **bridge 分发而非拼接。** 每种卡片一个分支,各自精确产出该卡片所需的协议格式(wire format),而非调和五个交互关系未文档化的可选字段。 +- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明 `card:'diff'`;bridge 输出 ACP `{type:'diff', path, oldText, newText}` 的 `ToolCallContent`(已存在于 SDK 的 `ToolCallContent` 联合类型中,此前 bridge 未使用)。这正是本次重设计解锁的能力。 -### 生产方映射 +### 生产者映射 -- `dsh-tool-fs` read → `generic`(`kind:'read'`,附带一个 follow-along `location`);write → `diff`(`oldText:null`);edit → `diff`(`oldText:old_string || null`,`newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` Read/Write/Edit 分支逐字段对应。 +- `dsh-tool-fs` read → `generic`(`kind:'read'`,附带一个 follow-along `location`);write → `diff`(`oldText:null`);edit → `diff`(`oldText:old_string || null`,`newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` 中 Read/Write/Edit 各分支逐字段对应。 - `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` 和 `bash_output`/`bash_kill` → `generic`。 - `dsh-tool-todo` → `generic`。 ### 终端回退的归属 -`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(bridge 在无能力路径上将 `output` 包裹为围栏代码块),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的 capability 门控行为。 +`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹在围栏代码块中),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。 ### 纯函数性保持不变 -`presentCall`/`presentResult` 仍然是 `args`(以及 `presentResult` 的 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件样式(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string`→`new_string`。 +`presentCall`/`presentResult` 仍然是 `args`(`presentResult` 还有 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件风格(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string`→`new_string`。 ## 相对路径显示标题 -`claude-agent-acp` 将文件卡片的标题路径相对于会话 cwd 做相对化处理(`toDisplayPath`):显示 `Read src/foo.ts` 而非 `/abs/proj/src/foo.ts`,同时保持 `locations[]`/`diff.path` **原始**(编辑器打开真实路径)。我们的 `presentCall` 是纯函数/仅依赖 args,无法看到会话 cwd,因此相对化发生在 **bridge**——bridge 已经将会话 cwd 传入工具调用渲染(与它用于解析 terminal 卡片标题的 cwd 相同)。bridge 仅对标题做相对化,通过对已知 `locations[0].path`/`diffs[0].path` 子串的精确结构化替换实现——对文件卡片类型通用,从不特判工具名。 +`claude-agent-acp` 将文件卡片标题中的路径相对于会话 cwd 做缩短处理(`toDisplayPath`)——显示 `Read src/foo.ts` 而非 `/abs/proj/src/foo.ts`——同时保持 `locations[]`/`diff.path` 为**原始路径**(编辑器打开真实路径)。我们的 `presentCall` 是纯函数/仅依赖 args,无法访问会话 cwd,因此这一相对化处理发生在 **bridge**,bridge 已经将会话 cwd 传入工具调用渲染逻辑(与它用于解析 terminal 卡片标题的 cwd 相同)。bridge 仅对标题做相对化,方式是对已知的 `locations[0].path`/`diffs[0].path` 子串做精确的结构化替换——对所有文件卡片类型通用,从不针对工具名做特殊处理。 ## 曾考虑的替代方案 -- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其结论明确推迟到两个真实工具和两个真实消费方存在后再做这个联合类型,而该门槛现已达到。 -- **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何都需要新的 bridge 代码来渲染,因此一个插件添加的变体如果被 bridge 静默丢弃,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟。 -- **保留可选字段大杂烩**:即「问题」一节所剖析的现状:无效状态可表示、字段交互未文档化、且完全无法请求 diff 卡片。 +- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。 +- **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟糕。 +- **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表达、字段交互无文档、且完全无法请求 diff 卡片。 ## 后果 -新的渲染意图是 bridge switch 处的编译中断变更——这是有意为之:渲染代码必须在卡片种类存在之前就位。无效的卡片/字段组合现已不可表示,bash 回退推导归 bridge 所有,工具只返回一个结构化形状。第四种卡片(表格、图表)的门槛是在同一个变更中编写其 bridge 分支。 +新的渲染意图会在 bridge 的 switch 处引发编译中断——这是有意为之:渲染代码必须先于卡片种类存在。无效的卡片/字段组合现已不可表达,bash 回退推导归 bridge 所有,工具只返回一个结构化形状。第四种卡片(表格、图表)的门槛是在同一个变更中编写其 bridge 分支。 ## 非目标 @@ -76,7 +76,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## 相关 -- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做标签化 render-intent 联合类型」)中的推迟决定。该门槛现已达到;本 RFC 即是那个联合类型。 -- 由 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:该 RFC 增加了一个持久化的 `meta` 通道,使 write/edit 在结果时发出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 站点一个,或新建文件的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 -- 将 `ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view(`_meta` terminal 卡片约定和 capability 门控不变;仅 harness 侧的展示类型改变)。 +- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 RFC 即为那个联合类型。 +- 被 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 +- 将 `ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view(`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。 - ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。 diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml index 25679116f6..f589fd9ddf 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-03-filesystem-directory-listing-seam.md: bb8d9c4deda18b320b85d548bbd5bcb32f1c1d72 -2026-07-03-filesystem-directory-listing-seam.zh.md: a0332fe6cec576ad1c5b4722e2decb87344aeee1 +2026-07-03-filesystem-directory-listing-seam.zh.md: ccc5ca67f58537134da5c5484b3d527ba84fe8d3 diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md index a0332fe6ce..ccc5ca67f5 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md @@ -1,53 +1,53 @@ # RFC:为文件系统 seam 添加直接目录列举能力 -Status: implemented - [English](2026-07-03-filesystem-directory-listing-seam.md) | 中文 +Status: implemented + ## 问题 -`@deepseek-ai/dsh-fs` 是文件系统访问的提供方 seam,本地后端与未来的非本地后端共享同一个 `ctx.fs` 契约。在本次变更之前,它能解析路径、stat 目标、读取文本、流式读取文本、写入文本和编辑文本。这对面向模型的文件工具已经够用,但对于需要枚举目录而又不想直接导入 `node:fs` 的非模型侧消费方来说还不够。 +`@deepseek-ai/dsh-fs` 是文件系统访问的提供方 seam,本地后端与未来的非本地后端共享同一个 `ctx.fs` 契约。在本次变更之前,它能解析路径、stat 目标、读取文本、流式读取文本、写入文本和编辑文本。这对面向模型的文件工具已经足够,但对于需要枚举目录而又不想直接导入 `node:fs` 的非模型侧消费方来说还不够。 -直接的压力来自 skill 加载:读取单个 `SKILL.md` 已经可以走 `ctx.get('fs')`,但发现哪些 skill 根目录下包含 `<name>/SKILL.md` 或 `<name>.md` 仍需要目录枚举。如果只在 `dsh-skill` 中添加目录列举,要么保留一个直接的 Node 依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。 +直接的压力来自 skill(技能)加载:读取单个 `SKILL.md` 已经可以走 `ctx.get('fs')`,但发现哪些 skill 根目录包含 `<name>/SKILL.md` 或 `<name>.md` 仍需要目录枚举。如果仅在 `dsh-skill` 中添加目录列举,要么保留对 Node 的直接依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。 -本决策只添加提供方能力,不引入面向模型的 `ls`/`list` 工具,也不改变 skill 发现逻辑。那些消费方需要独立的 UX、提示词和策略决策。 +本决策只添加提供方能力,不涉及面向模型的 `ls`/`list` 工具或 skill 发现机制的变更。那些消费方需要独立的 UX、prompt 与策略决策。 ## 决策 在 `@deepseek-ai/dsh-fs` 中添加 `FileSystem.listDir(target, signal?)`。 -`listDir` 仅列举一级目录。它以稳定的名称顺序返回直接子项,包含: +`listDir` 仅列举一层目录。它以稳定的名称顺序返回直接子项,包含以下字段: -- `name`:子项的 basename。 -- `type`:`file`、`directory` 或 `other`。 -- `target`:已解析的子项 `FsTarget`。 -- `version`:可用时提供的轻量元数据。 -- `size`:可用时提供的常规文件大小。 +- `name`:子项的 basename; +- `type`:`file`、`directory` 或 `other`; +- `target`:已解析的子项 `FsTarget`; +- `version`:可用时返回的轻量元数据; +- `size`:可用时返回的常规文件大小。 它从不读取文件内容。递归遍历、glob 匹配、分页、搜索、文件监听和面向模型的渲染均有意不在范围内。 -本地后端通过 `readdir({ withFileTypes: true })`、`resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的提示词/列表输出稳定,并提升前缀缓存复用率。 +本地后端通过 `readdir({ withFileTypes: true })`、`resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的 prompt/列表输出稳定,并提高前缀缓存复用率。 -损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。列举目录或解析/探测子项元数据时遇到的权限或后端 I/O 故障会以结构化的 `FsError` 代码使整个列举失败: +损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。在列举目录或解析/探测子项元数据时遇到权限或后端 I/O 故障,则以结构化的 `FsError` 错误码使整个列举失败: -- `FS_NOT_FOUND`:目标不存在。 -- `FS_NOT_DIRECTORY`:目标存在但不是目录。 -- `FS_PERMISSION_DENIED`:权限不足。 -- `FS_IO_ERROR`:其他后端 I/O 故障。 +- `FS_NOT_FOUND`:目标不存在; +- `FS_NOT_DIRECTORY`:目标存在但不是目录; +- `FS_PERMISSION_DENIED`:权限不足; +- `FS_IO_ERROR`:其他后端 I/O 故障; - `FS_ABORTED`:调用被中止。 ## 曾考虑的替代方案 -**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其提示词、schema 和渲染契约与提供方原语无关。 +**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其 prompt、schema 和渲染契约与提供方原语相互独立。 -**让每个消费方自行枚举目录。** 否决。这会把 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。 +**让每个消费方自行枚举目录。** 否决。这会将 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。 -**让 `listDir` 支持递归或 glob 形式。** 暂时否决。skill 根目录发现只需要直接子项,简单的单级列举是未来消费方可以安全组合的最小后端契约。 +**让 `listDir` 支持递归或 glob 形式。** 暂时否决。skill 根发现只需要直接子项,而简单的单层列举是未来消费方可以安全组合的最小后端契约。 -**跳过元数据解析失败的子项。** 否决。API 承诺返回已解析的子项 target,因此解析子项时遇到的权限/IO 故障属于契约失败。损坏或已消失的子项是例外,因为它们仍可在不声称拥有一个活跃已解析文件的前提下被表示。 +**跳过元数据解析失败的子项。** 否决。API 承诺返回已解析的子项 target,因此解析子项时的权限/IO 故障属于契约失败。损坏或已消失的子项是例外,因为它们仍可在不声称拥有一个活跃已解析文件的前提下被表示。 ## 后果 每个文件系统后端现在必须多实现一个提供方原语。这是 harness 尚未发布时有意为之的基础工作,但也意味着未来的沙箱/远程后端需要定义等价的直接子项列举行为。 -该能力仍然面向提供方。在消费方落地之前,ACP/模型会话仍需使用 `bash` 等既有工具来列举目录。没有面向模型的 `listdir` 工具是预期行为,而非接线遗漏。 +该能力仍停留在提供方层面。在消费方落地之前,ACP(Agent Client Protocol)/模型会话仍需使用 `bash` 等既有工具来列举目录。缺少面向模型的 `listdir` 工具是预期行为,而非接线错误。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 8ae2221209..5c821e12fa 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-prompt-variables-and-tool-guidance-ownership.md: fce9d555c8843b99fdbfa7b652b46d0b88053935 -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 5af4fca19649d4ee458eaa6a23ae7374abdd89e4 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 93a639a2ddac33cb1ceac57101cb6185fe6034ad diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 5af4fca196..93a639a2dd 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -1,72 +1,72 @@ -# RFC:提示词变量与工具指导归属 - -Status: implemented +# RFC:Prompt 变量与工具指导归属 [English](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 中文 +Status: implemented + ## 问题 -组装后的系统提示词有四个缺陷,同属一类:harness 已经掌握的事实在别处被手工重述,然后漂移。 +组装后的系统提示词存在四个缺陷,同属一类:harness 已知的事实在别处被手工重述,然后漂移。 -**模型无法知道自己的名字。** `AgentOptions.model` 驱动每次请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,且 `assemble()` 根本不接受任何 per-agent 输入。 +**模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何 prompt 文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 -**工具指导是叶子 YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml` 和 `examples/acp-agent/cordis.yml` 的 `systemPrompt` 字符串中——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则以 `ctx.systemPrompt.section()` 贡献的方式持有各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种割裂的症状道歉,stdio 的欢迎横幅也手动枚举了工具集。 +**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml` 和 `examples/acp-agent/cordis.yml` 的 `systemPrompt` 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,stdio 的欢迎横幅也手动枚举了工具集。 -**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「使用 read 工具……」再读到「你是 coding-agent」——与身份优先的惯例(Claude Code、Codex)相反,且在 section 流水线之外形成了第二条组合路径。 +**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are coding-agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 -**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义撰写的描述——"a separate agent that works in its own context … it does not see this conversation"——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题同族:`PromptSection.name` 文档写着"(diagnostics / dedup)",但重复项被静默接受。 +**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义编写的描述——"a separate agent that works in its own context … it does not see this conversation"——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题:`PromptSection.name` 文档标注为 "(diagnostics / dedup)",但重复项被静默接受。 ## 决策 -**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包的 prompt section。harness 出处 → 静态的 `harness:identity` section。部署角色和行为 → 部署的 persona。 +**一条原则:prompt 中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包(package)的 prompt section。harness 来源标识 → 静态的 `harness:identity` section。部署角色与行为 → 部署的 persona。 ### 组装上下文 -`SystemPrompt.assemble(context)` 接受一个可 merge 扩展的 `AssembleContext`。`dsh-system-prompt` 声明用于 scoped routing 的可选 `scope` 选择器,而 `dsh-agent` 通过 declaration-merge 将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖环)。循环在每一步调用 `assembleContextFor(agent)`,使两个字段标识同一个 agent;section 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall(瀑布式事件)也会收到它,监听方可据此按 agent 过滤或扩展。 +`SystemPrompt.assemble(context)` 接受一个可合并扩展的 `AssembleContext`。`dsh-system-prompt` 声明可选的 `scope` 选择器用于 scoped 路由,而 `dsh-agent` 通过声明合并将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖循环)。循环在每个步骤调用 `assembleContextFor(agent)`,使两个字段标识同一个 agent;section 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall(瀑布式事件)也接收它,监听器可据此按 agent 过滤或扩展。 -### 提示词变量 +### Prompt 变量 -插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装时将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝:未知的 own-property 引用、注册的 provider 返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被再次扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 +插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用了未知的 own-property、已注册的 provider 返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 -`dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下文的 section):它们是本循环所驱动的 agent 的运行时事实,替换循环自行提供自己的变量。 +`dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。 ### Persona 作为 order-0 section -`dsh-system-prompt` 持有 order 为 `-100` 的 `harness:identity` 和 order 为 `0` 的已配置 `deployment:persona`,因此两者在替换循环时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,`agent/pre-step` 因此能测量用于压缩(compaction)的确切提示词。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 分段为:identity `-100`、persona `0`、工具指导 `100–199`。 +`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 `0` 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,`agent/pre-step` 因此测量的正是用于压缩(compaction)的确切 prompt。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 ### 工具指导归属 -每个工具的语义和选择指导存放在工具描述中。Prompt section 仅承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的描述已包含完整契约。部署 persona 只包含角色和行为。 +每个工具的语义和选择指导放在工具 description 中。prompt section 只承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的 description 包含完整契约。部署 persona 只包含角色和行为。 ### Subagent 对话历史描述符 -`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和 prompt 参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一事实。提供方生命周期事件使该措辞与响应式的 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。 +`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。 ## 曾考虑的替代方案 -- **循环自行组合一行身份文本**——在必须保持精简的那个包里硬编码面向模型的行文("plugins, not loop changes"),且在 section 流水线之外形成第二条组合路径。(身份确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署方需要移除它时的逃生阀。) -- **通过 `agent/request` waterfall 注入模型名称**——提示词文本在两处组合,且 `agent/pre-step` 的 `fullSystemPrompt` 会遗漏它,导致压缩(compaction)测量的提示词与模型实际看到的不一致。 -- **在每个 persona 中手写模型名称**——与上方一行的 `model:` 键重复,配置修改后默默失实——正是本 RFC 要治的病。 -- **宽松插值(未知引用保留原样或替换为空)**——一个拼写错误 `{{modle}}`(或一个空洞)会被送到模型,直到 transcript(文本记录)审查才有人注意到。 -- **在配置中逐实例手写 subagent 措辞**——面向模型的行文重新回到每个部署 × 每个实例,又是同一个病。**按 provider 名称匹配措辞**——`providerName` 本身是配置,重命名 provider 后会静默拿到错误的措辞。 -- **在 `apply` 时解析 provider(加载顺序要求)** 和 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**——provider 生命周期事件的替代方案;均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。 +- **循环自行组合一行 identity 文本**:在必须保持精简的那个包("用插件,不改循环")中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) +- **通过 `agent/request` waterfall 注入模型名称**:prompt 文本在两处组合,且 `agent/pre-step` 的 `fullSystemPrompt` 会遗漏它,导致 compaction 测量的 prompt 与模型实际看到的不一致。 +- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 RFC 要治愈的病症。 +- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 +- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**:`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。 +- **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。 ## 不在范围内 -- 更多变量(`date`、平台、git 状态)——注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。 -- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化)——推迟到 session-cwd 方案重新讨论时。 +- 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。 +- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到 session-cwd 方案重新讨论时。 ## 交付的不变式 -- coding-agent 提示词通过一条组装路径渲染:identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 +- coding-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 - fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。 -- 未知、无值、格式错误或不平衡的变量引用会指名 section 并抛出异常;重复的 section、变量和工具注册也会抛出异常。 -- 快照回放与提示词无关:它按轮次和步骤索引已录制的 chunk 流,不比较发出的请求。 +- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 +- 快照回放与 prompt 无关:它按轮次和步骤索引已记录的 chunk 流,不比较发出的请求。 ## 后果 -- 组装后的提示词中每个事实现在恰好有一个归属方,叶子 YAML 中手写的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 -- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词中的声明在该步骤就会过时;如果一个插件在那里**提供**模型(options.model 未设置——循环文档记载的回退路径),变量在渲染时无值,含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,且正是归属规则本身:拥有该延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 -- 当一个已绑定的 provider 不在位(尚未激活、已卸载、HMR(热模块替换)重载中)时,subagent 工具不存在,该窗口内的模型请求只是缺少它。这是诚实的状态——替代方案是一个描述或执行都不可信的已注册工具。 -- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——而且这是一个我们希望大声暴露的撰写错误。 -- 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,届时再添加。 +- 组装后的 prompt 中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 +- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,prompt 对该步骤的声明就会过时;如果一个插件在那里**提供**模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 +- 当一个已绑定的 provider 不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 +- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们**希望**大声暴露的撰写错误。 +- 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,再行添加。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 7357dfe0f8..2ef938def7 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-reconstructable-requests.md: 0978cd8760c6a0420be1bf0a3baf6b50c1a04a13 -2026-07-05-reconstructable-requests.zh.md: 82f9cf085db3d6cd408e54a8e7cf99082d848176 +2026-07-05-reconstructable-requests.zh.md: a4864c9e795ccc0da2cbbf0ca4d17a90c029858c diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 82f9cf085d..a4864c9e79 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -1,4 +1,4 @@ -# RFC:每个 LLM 请求都可从会话日志重建 +# RFC:每个 LLM(大语言模型)请求都可从会话日志重建 Status: implemented @@ -6,50 +6,50 @@ Status: implemented ## 问题 -请求流水线此前不保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。 +请求流水线未能保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。 -快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只追加、从不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型必须看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。 +快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只做追加而不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型需要看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。 ## 决策 ### 原则 -**模型可见 ⟺ 已记录。** 凡到达模型请求的内容,都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何持有日志的人都能逐字节重建它。精确的范围说明:保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由它推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对已记录区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。 +**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由此推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。 -前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。逐字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3。 +前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3。 ### 机制 -**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次;surface 改写(压缩的 `replace`——`SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,其中的消息是共享的、深度冻结的:通过投影修改已记录的历史是不可表达的(会抛异常),取代了旧的每次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 +**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 -`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 写入完整的初始、恢复或回退快照。`request/header-delta` 通过公共前缀/后缀行裁剪编码系统提示词变更,通过按名称键控的增/删/改编码工具变更,通过完整替换编码配置或前缀变更。`foldRequestHeader`、`diffHeader` 和 `applyHeaderDelta` 是纯编解码器。每个循环实例在其首次请求时写入一个快照,以锚定进程边界。Delta 仅是优化:写入方验证往返等价性,对不可表达的变更(如纯工具重排序)回退到完整快照。 +`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 写入完整的初始、恢复或回退快照。`request/header-delta` 通过公共前缀/后缀行裁剪编码系统变更,通过按名称键控的增/删/改编码工具变更,通过完整替换编码配置或前缀变更。`foldRequestHeader`、`diffHeader` 和 `applyHeaderDelta` 是纯编解码器。每个循环实例在首次请求时写入一个快照以锚定进程边界。delta 只是优化:写入方验证往返等价性,对无法表达的变更(如纯工具重排序)回退到完整快照。 -每一步重建 prompt 组装。实例的第一步中,`agent/session-prefix` 用仅限请求的开场消息扩展一个冻结的空种子;结果被冻结并缓存于该循环实例。`agent/pre-step` 随后在消息快照紧接 `step/start` 之前接收组合后的前缀。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,而模型可见的内容通过已记录的通道进入。循环记录欠写的 header 事件(前缀唯一的持久化归属),从前缀、快照和 header 构建 `GenerateOptions`,并深度冻结它,同时保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和其锚定快照是否已写入。 +每个步骤重建 prompt 组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果被冻结并缓存于该循环实例。`agent/pre-step` 随后接收组合后的前缀,消息在 `step/start` 之前立即被快照。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。 -**`step/start` 是重建边界。** 一步从该序列之前的事件派生消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step` 是当前请求所需内容的 seam。Header 重建折叠该步骤自身的 `request/header*` 事件,或在无新 header 写入时沿用前一次折叠结果。 +**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step` 是当前请求所需内容的 seam。header 重建通过该步骤自身的 `request/header*` 事件折叠,或在无新 header 写入时沿用前一次折叠结果。 -**强制执行。** 在开发环境中,`dsh-invariants` 通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环请求通过其冻结形态和 session id 识别;直接的一次性调用被排除。正确性依赖于序列有界的重建而非监听器顺序。带密钥的 e2e 要求首次请求之后出现正数的 cache-read token;逐步 usage 是生产信号,header 变更或压缩表现为下一步 cache-read 的下降。 +**强制执行。** 在开发环境中,`dsh-invariants` 通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环请求通过其冻结形状和 session id 识别;直接的一次性调用被排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。 ### MiniCode 形态:采纳,但溯源箭头反转 -与 MiniCode 一样,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同的是,事件日志仍是真源,因为它还拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志派生的消息和 header 折叠结果,使每个请求都可独立检查。 +与 MiniCode 相同,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同,事件日志仍是真源,因为它同时拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志推导的消息和 header 折叠结果,使每个请求都可独立检查。 ## 曾考虑的替代方案 -- **客户端作为真源**(照搬 MiniCode):在日志之外出现第二个生效的真相——两者漂移而无人察觉;见上节。 +- **客户端作为真源**(照搬 MiniCode):在日志之外多出一个运行时真相——两者漂移而无人察觉;见上节。 - **镜像日志的有状态传输客户端**:重复对话状态,需要围绕监听器做回滚,留下未记录的编辑面,且仍无法重建请求 header。Session 拥有的缓存加已记录的 header 避免了这些分裂的真相。 -- **逐次调用的请求标量**(每次 `agent/request` 分发时传入一个可自由修改的配置):监听器可以零记账地逐次切换 model,悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态;waterfall(瀑布式事件)提议,日志记录。 -- **检测并报告**(比较连续请求,发现分歧时警告):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。 -- **事件驱动组装**(仅在变更信号时重新渲染):存在信号遗漏的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步渲染加值比较在零信号纪律下仍然健壮。 -- **Header 事件上的叙事字段**(delta 上的 `reason`/`changed` 列表):可通过 diff 连续事件派生——每个事实只有一个归属;快照携带 reason 是因为锚点的成因无法从数据本身派生。 +- **逐次调用的请求标量**(一个可自由变异的配置传给每次 `agent/request` 分发):监听器可以零记账地逐次切换 model,悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态;waterfall(瀑布式事件)提议,日志记录。 +- **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。 +- **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。 +- **Header 事件上的叙事字段**(delta 上的 `reason`/`changed` 列表):可通过 diff 连续事件推导——每个事实只有一个归宿;快照携带 reason 是因为锚点的成因无法从数据推导。 ## 后果 -- 一个无法由日志解释的请求不可能被意外构造——无论是循环还是监听器;修改已构建的请求会抛异常;每次 header 变更都是一个持久的、可 diff 的日志事件。 -- 在建议通道之间做选择是变更频率决策,而本设计让稳定的那个成为结构性的:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此它以零边际成本扩展可缓存前缀,且**不可能**在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每个都是持久的 `context/message`,付出一次代价后即享受前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了所有当前更新模式)。 -- 在提供方处仍需全价的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一个不同)。提供方自身的 reasoning-content 排除由服务端管理。 +- 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。 +- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且**不可能**在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 +- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一快照不同)。提供方自身的 reasoning-content 排除由服务端管理。 - `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。 -- 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属于压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 -- 会话日志每个对话增长一个 `request/header` 快照(系统提示词 + 工具 schema:主导项),加上真正变更时的 delta——相对于 `assistant/chunk` 的体量很小;`SESSION_FORMAT_VERSION` 保持 `0`(预发布期间的变动被吸收,后端拒绝而非迁移)。 -- 快照 golden 文件变更一次(每份 transcript 增加其 header 事件);写文件系统的 fixture 以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只往返 cwd 无关的参数路径。 -- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特有的额外项(reasoning 选项、额外 body 参数)应归属何处。 +- 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 +- 会话日志每个对话增长一个 `request/header` 快照(系统提示词 + 工具 schema:主导项),加上真正变更时的 delta——相对 `assistant/chunk` 的体量很小;`SESSION_FORMAT_VERSION` 保持 `0`(预发布期间的变动被吸收,后端拒绝而非迁移)。 +- 快照 golden 文件变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 +- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(reasoning 选项、额外 body 参数)应归属何处。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 1bf06ce567..74fbfda121 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-subagent-provider-lifecycle-events.md: 6d711f2a6d8496a8a229ec63d86dd89816efb6f8 -2026-07-05-subagent-provider-lifecycle-events.zh.md: 45eedcfdfa834722000c9f2c15e3955ced791c2f +2026-07-05-subagent-provider-lifecycle-events.zh.md: f412b031644c14ca70caae3efc72eefa9ce2c2ac diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index 45eedcfdfa..f412b03164 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -1,36 +1,36 @@ # RFC:Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed` -Status: implemented - [English](2026-07-05-subagent-provider-lifecycle-events.md) | 中文 +Status: implemented + ## 问题 -[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 使 `dsh-tool-subagent` 从其提供方**派生**面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),从而让 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在**工具注册时**就已固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方**派生**面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在**工具注册时**固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 -如果在工具插件的 `apply` 时刻解析提供方,就会产生隐式的加载顺序要求("在 cordis.yml 中把后端列在工具前面")。这一要求行不通,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不等待激活完成:一个延迟到达的后端可能导致工具 fiber 失败,即使它在配置中列在前面也是如此。Loader 不提供同级顺序保证——"异步状态不是同步状态"(见[防御性模式](../../../defensive-patterns.md))。 +如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求("在 cordis.yml 中把后端列在工具前面")。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——"异步状态不是同步状态"(见[防御性模式](../../../defensive-patterns.md))。 ## 决策 -注册表将提供方的成员变动作为类型化事件广播,消费方镜像这些事件而非假设顺序: +注册表将提供方的成员变化作为类型化事件广播,消费方镜像这些事件而非假设顺序: - **`subagent/provider-added(provider)`**:一个提供方在 `ctx.subagents` 注册表中变为可解析。在注册时发出。 -- **`subagent/provider-removed(name)`**:一个提供方离开了注册表(其插件 fiber 被 dispose——卸载或 HMR 重载)。从注册的 disposer 中发出。 +- **`subagent/provider-removed(name)`**:一个提供方离开注册表(其插件 fiber 被 dispose(资源释放)——卸载或 HMR(热模块替换)重载)。从注册的 disposer 中发出。 -`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞;当提供方离开时注销工具;在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不可能对模型撒谎。这里**刻意不留**任何需要文档化的加载顺序要求:事件使顺序问题消失,而非将其钉死。 +`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞——当提供方离开时注销工具,并在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不会对模型撒谎。这里有意**不留下**任何需要文档化的加载顺序要求:事件让顺序问题消失,而非将其钉死。 -这些事件还补全了该 seam 的词汇:`ctx.subagents` 是一个命名注册表,多个委派后端(`spawn`、`fork`、`acp`)在其上共存;一个内容会被其他插件用来派生状态的注册表,应当以类型化事件广播成员变动,而非要求轮询或依赖加载顺序。 +这些事件还完善了 seam 的词汇:`ctx.subagents` 是一个命名注册表,多个委派后端(`spawn`、`fork`、`acp`)在其上共存;一个其他插件从中派生状态的注册表,应当以类型化事件广播成员变化,而非要求轮询或依赖加载顺序。 ## 曾考虑的替代方案 -- **在 `apply` 时解析提供方,不存在则抛异常**:否决。"先列后端"会声称一个 Loader 并不提供的顺序保证。 -- **重试查找(轮询直到提供方出现)**:最终会收敛,但在框架已有的机制(effect 注册 + disposal)之外自行发明了一套私有就绪协议;而且它无法感知提供方**离开**,因此 HMR 会让一个措辞描述着已 dispose 后端的工具滞留。 -- **仅在 section 中放置 subagent 措辞,在组装时延迟解析**:同样能容忍任意加载顺序,但把 tool-choice 引导移出了描述,与 prompt-variables RFC 确立的归属规则相矛盾(每个工具的语义和使用时机属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 -- **根据提供方名称而非提供方对象来确定措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。 +- **在 `apply` 时解析提供方,不存在则抛异常**:否决。"先列后端"这一要求声称了 Loader 并不存在的顺序保证。 +- **重试查找(轮询直到提供方出现)**:最终能收敛,但在框架已有的机制(effect 注册 + disposal)之外发明了一套私有就绪协议;它也无法感知提供方**离开**,因此 HMR 会遗留一个措辞描述已 dispose 后端的工具。 +- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但将 tool-choice 引导移出了**描述**,与 prompt-variables RFC 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 +- **根据提供方名称而非提供方对象确定措辞**:`providerName` 本身是配置,重命名后的提供方会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。 ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 -- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录,不会饿死后续镜像或扰乱拆卸流程。`start()` 仍然在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../cordis-catalog/events.md)和[生产者/消费者映射](../../../event-producer-consumer.md)。 -- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处派发的工具——工具注册表的 `tools/change` 事件确保 prompt 组装保持最新。 -- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,且被延迟捕获。** 如果两个 `dsh-tool-subagent` 实例命名了不同的提供方但相同的 `toolName`,二者都会等待,先到达的提供方触发注册;第二个注册仅在**其**提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一爆炸半径;工具注册表的重名拒绝机制仍是最终兜底。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../cordis-catalog/events.md)与[生产者/消费者映射](../../../event-producer-consumer.md)。 +- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持 prompt 组装的时效性。 +- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在**其**提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index cc697c3e30..a051d20478 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-timeout-deadline-library.md: 9906aa7cce40ffd7b5f7082199d05edb8d0a54b2 -2026-07-06-timeout-deadline-library.zh.md: ef99a1a051f3cedbe5a2770e5bbeeebe716745c4 +2026-07-06-timeout-deadline-library.zh.md: dd1a60f9d7b91575b32e1cc85b3800cf823b6a6d diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index ef99a1a051..dd1a60f9d7 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -1,22 +1,22 @@ -# RFC:共享的超时/截止时间原语,hard-kill 留给各能力自行实现 - -[English](2026-07-06-timeout-deadline-library.md) | 中文 +# RFC:共享的超时/截止时间原语,硬终止留给各能力自行实现 Status: implemented +[English](2026-07-06-timeout-deadline-library.md) | 中文 + ## 问题 -超时处理在各个承载工具的能力之间逐渐分化,而这种分化并非表面的——同一套逻辑被三种方式各自重新实现,每种都带着自己微妙的正确性负担。 +超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器——用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器——各自调用同一个 `kill()` 闭包,该闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)各自独立锁存。 -- **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手工搭建*的超时:它构造一个 `AbortController`,接入 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因——因为 reader 只抛出裸 `AbortError`。 -- **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本 RFC 中保持无超时——见「后果」。) +- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 +- **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) -每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「被取消」——而融合和原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 舞步就是证据)。与此同时,各能力执行的*终止*动作不可归约地不同:bash 杀的是 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止的是进程内的 `fetch`(undici 拆掉 socket)。不存在一种单一机制能停止所有这些工作。 +每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「已取消」。而融合与原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 处理就是证据)。与此同时,各能力执行的*终止*操作不可归约地不同:bash 杀死一个 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止一个进程内的 `fetch`(undici 拆除 socket)。不存在一个能停止所有能力工作的单一机制。 ## 决策 -`@deepseek-ai/dsh-timeout` 位于 `packages/util/`(与 `dsh-brand` 同级),拥有超时的*计时与分类*这一半;*终止*那一半——hard kill——留在各能力的实现中。它是一个纯函数库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何东西、不持有跨调用状态、不发射事件。刻意不设中央「超时服务」——那样的服务必须知道如何停止每个能力的工作,而这正是微内核要排除在共享层之外的知识,也是 Codex 的 `ExecExpiration` 作用域仅限于 exec 家族所示范的。 +`@deepseek-ai/dsh-timeout` 位于 `packages/util/`(与 `dsh-brand` 同级),负责超时的*计时与分类*这一半;*终止*那一半——硬终止——留在各能力的实现中。它是一个纯函数库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何东西、不持有跨调用状态、不发射事件。这里刻意不设中央「超时服务」,因为那样的服务必须知道如何停止每个能力的工作——而这正是微内核要排除在共享层之外的知识,也是 Codex 将 `ExecExpiration` 限定于 exec 族所示范的原则。 ### 库的对外接口 @@ -57,42 +57,42 @@ export function deadline( export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined ``` -`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的无超时哨兵,用于后端自有的后台任务;外部提示经 `clampTimeout` 后必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,但具有相同的 disposal 形状。提供方将超时原因翻译为 seam 特定的结果。`timeoutOf(signal, code)` 通过 code 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力的超时。 +`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的「无超时」哨兵,用于后端拥有的后台任务;外部提示经过 `clampTimeout`,必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。提供方将超时原因转译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。 -### 分工 +### 职责划分 | 关注点 | 负责方 | |---|---| -| 校验请求提示并钳位 default/max | `dsh-timeout`(`clampTimeout`)——纯算术加共享的正有限请求契约 | +| 校验请求提示并钳位默认值/最大值 | `dsh-timeout`(`clampTimeout`):纯算术加共享的正有限请求契约 | | 启动定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) | | 清除定时器 | `dsh-timeout`(`[Symbol.dispose]`) | -| 中止后分类首个 abort reason | `dsh-timeout`(`timeoutOf`) | +| 中止后对首个 abort reason 进行分类 | `dsh-timeout`(`timeoutOf`) | | **实际终止工作** | 各能力的实现 | -| default/max *值* | 各能力的配置 | +| 默认值/最大值*数值* | 各能力的配置 | | 超时 `code` 字符串 | 各能力(`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT`) | -信号只*通知*;终止始终是监听者的职责,而监听者因能力而异。bash 自己写 `addEventListener('abort', kill)`,因为 OS 进程活在本运行时之外,没有别的东西会杀它;web 把 `d.signal` 交给 `fetch`,undici 拆掉 socket。这也是文件 read/write/edit 不接受 **`timeoutMs`** 的原因:本地系统调用至多只能尽力中止,超时无法强制 `fsync`/`rename` 停下,加一个超时等于引入一个违反「显式优于隐式」的隐式默认值。两个参考 agent 出于同样的理由都不给文件 I/O 设超时。 +信号只*通知*;终止始终是监听方的职责,而监听方因能力而异。bash 自行编写 `addEventListener('abort', kill)`,因为 OS 进程存在于本运行时之外,没有别的东西会杀死它;web 将 `d.signal` 交给 `fetch`,由 undici 拆除 socket。这也是文件读/写/编辑**不接受** `timeoutMs` 的原因:本地系统调用最多只能尽力中止,超时无法强制 `fsync`/`rename` 停止,添加超时将是一个违反「显式优于隐式」的隐式默认值。两个参考 agent 出于同样的原因对文件 I/O 不设超时。 -### 各能力如何消费 +### 各能力如何消费该库 -- **web_fetch**——工具层保持校验并转发;提供方手工搭建的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被提供方自有的 `deadline`/`timeoutOf` 取代。上游信号已预先中止时仍立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 -- **bash**——`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 +- **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 +- **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 ## 后果 - `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,seam 类型 `BashRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 -- `SpawnSpec.timeoutMs` 与 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残留保留:`runBash` 不再拥有定时器、执行器拥有分类逻辑后,它们无处被读取。这是与字面提案形状(向 `runBash` 传 `timeoutMs: 0`)的唯一偏差;在逐文件覆盖率门禁下,一个始终为 0 且无人读取的字段是死代码。 -- web_fetch 去掉了自建的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 -- `AbortSignal.any` 与 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 +- `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。这是与字面提案形状(向 `runBash` 传入 `timeoutMs: 0`)的唯一偏差;一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 +- web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 +- `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 -不在本 RFC 范围内,列出以标明边界:`web_search` 可以在其 tool-schema/快照覆盖率规划完成后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,hard kill 仍是各能力自己的事。 +以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其 tool-schema/snapshot 覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 ## 曾考虑的替代方案 -**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核理由否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查)——这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 作用域仅限于 exec 家族,正是因为它驱动的 kill(`killpg`)是进程家族特有的;MCP 和 model-stream 各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 +**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和 model-stream 各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 -**每个工具各自实现超时,不共享代码(之前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手工搭建的 controller/reason 逻辑正是未来每个网络/进程工具都要重新推导的,而融合 + `signal.reason` 恢复是容易出错的部分。Claude Code 容忍完全重复;本仓库有一条统一的共享中止通道(每次 `execute` 上的 `exec.signal`),使一个小型共享原语严格更干净,因此成本/收益不同。 +**每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得一个小型共享原语严格更优,因此成本/收益不同。 -**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在 deadline 时 resolve *工具调用*的 promise,而不停止底层工作——子进程或 fetch socket 会泄漏。发出信号并要求能力去监听,才能强制一条真正的终止路径存在。这与「dispose 必须达到静止态,而非仅仅请求它」的防御性规则一致。 +**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到静止状态,而非仅仅请求它」的防御性规则一致。 -**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了自建定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径不变。 +**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index 36a4652f26..31025407be 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-tool-call-timeout-policy.md: 0e69c8504dd34dfc4427bf1f3865a80be93eff9d -2026-07-07-tool-call-timeout-policy.zh.md: d842d0a3c811e7502f4d45baba49010a06c5f1a5 +2026-07-07-tool-call-timeout-policy.zh.md: 2f4eca6eff9d135aad3fe538b887402a42ac6f84 diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index d842d0a3c8..2f4eca6eff 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -1,24 +1,24 @@ # RFC:工具调用超时策略作为插件 -Status: implemented - [English](2026-07-07-tool-call-timeout-policy.md) | 中文 +Status: implemented + ## 问题 -[超时/截止时间 RFC](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵守 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写形态:工具作者通常只需将 `exec.signal` 转发给所调用的实现,而部署策略来决定预算。 +[超时/截止时间 RFC](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 -与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 来执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一后端复用了前台执行、后台启动、后台轮询和钩子调用。一步到位地把所有超时都移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 +与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子调用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 ## 决策 -工具调用超时是一项仅适用于面向模型的工具执行的策略,由三部分组成: +工具调用超时是仅适用于面向模型的工具执行的策略,由三部分组成: -- `@deepseek-ai/dsh-timeout` 仍然是拥有 `deadline()` 和 `timeoutOf()` 的共享库。 +- `@deepseek-ai/dsh-timeout` 仍是拥有 `deadline()` 和 `timeoutOf()` 的共享库。 - `@deepseek-ai/dsh-tools` 在 `tools/pre-execute` 和 `tools/post-execute` 之间有一个环绕分发的 waterfall(瀑布式事件)`tools/execute`。 - `@deepseek-ai/dsh-timeout-policy` 从注册表读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 -执行流水线为: +执行流水线如下: ```text ctx.tools.execute(exec) @@ -30,17 +30,17 @@ ctx.tools.execute(exec) -> tools/post-execute ``` -默认行为是保守的:未声明 `timeoutMs` 的工具不会从该插件收到 `TOOL_TIMEOUT` 截止时间。 +默认行为是保守的:未声明 `timeoutMs` 的工具不会从该插件收到 `TOOL_TIMEOUT` 截止信号。 ### `tools/execute` 环绕 seam -`@deepseek-ai/dsh-tools` 声明了一个 `tools/execute` waterfall,其基础 `next()` 是「分发并规范化」的 thunk:即同一个内部 `try`/`catch`,它将抛出的工具错误(或未知工具错误)转换为 `isError` 的 `ToolExecutionResult`。监听器接收 `(exec, next)`:调用 `next()` 委托给分发(返回其结果,可选地包装),或返回替代结果以短路分发。整条流水线仍处于 `execute` 的外层 try/catch 之内,因此抛出异常的监听器会变成 `isError` 结果,永远不会导致轮次失败。 +`@deepseek-ai/dsh-tools` 声明了一个 `tools/execute` waterfall,其基础 `next()` 是带规范化的分发 thunk——即同一个内部 `try`/`catch`,将抛出的工具错误(或未知工具错误)转换为 `isError` 的 `ToolExecutionResult`。监听器接收 `(exec, next)`:调用 `next()` 委托给分发(返回其结果,可选地包装),或返回替代结果以短路分发。整个流水线仍位于 `execute` 的外层 try/catch 内,因此抛出异常的监听器会变成 `isError` 结果,而非轮次失败。 -catch 是基础 `next()` 而非 waterfall 之外的东西,这一点是关键:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为正常的错误结果,然后 `timeout-policy` 才能将最终结果替换为 `TOOL_TIMEOUT`。 +catch 是基础 `next()`(而非 waterfall 之外的东西)这一点至关重要:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为普通错误结果,然后 `timeout-policy` 才能将最终结果替换为 `TOOL_TIMEOUT`。 ### `timeout-policy` 插件 -该插件是 `@deepseek-ai/dsh-timeout-policy`,位于 `packages/timeout/` 分组中,是一个零配置的函数/命名空间插件(`name` / `inject` / `apply`)。每个工具的预算声明在工具自身上,而非此插件上:`ToolDefinition` 携带可选的 `timeoutMs`,由拥有该工具的插件从自身配置中设置。例如 `dsh-tool-web` 将 `fetchTimeoutMs` / `searchTimeoutMs`(默认 30000)解析到 `web_fetch` / `web_search` 的定义上: +该插件是 `@deepseek-ai/dsh-timeout-policy`,一个零配置的函数/命名空间插件(`name` / `inject` / `apply`),位于 `packages/timeout/` 组。每个工具的预算声明在工具自身,而非本插件:`ToolDefinition` 携带一个可选的 `timeoutMs`,由拥有该工具的插件从自身配置中设置。例如 `dsh-tool-web` 将 `fetchTimeoutMs` / `searchTimeoutMs`(默认 30000)解析到 `web_fetch` / `web_search` 的定义上: ```yaml - id: timeout-policy @@ -52,11 +52,11 @@ catch 是基础 `next()` 而非 waterfall 之外的东西,这一点是关键 searchTimeoutMs: 30000 ``` -超时声明在工具定义上而非自由文本的名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 会校验预算为正有限数。分发期间,执行器派生截止时间信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 +超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 -信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的参数,使用共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此 Cordis 的文档惯用法——修改共享对象再委托——是唯一能到达分发的机制。插件在 `finally` 中将 `exec.signal` 恢复为调用方的原始信号,使 `tools/post-execute` 永远不会看到此插件的(可能已中止的)截止时间信号。 +信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此 Cordis 的惯用方式——修改共享对象再委托——是唯一能到达分发的机制。插件在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的(可能已中止的)截止信号。 -`timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止时间代码(作用域化,使嵌套的外层截止时间读取为普通取消),以及结构化工具结果的错误代码。其替换结果为: +`timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止代码(有作用域,使嵌套的外层截止读为普通取消)和结构化工具结果错误代码。其替换结果为: ```ts ignore-check function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { @@ -68,44 +68,44 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { } ``` -这是一个协作式截止时间。它不会通过与工具 promise 竞速来杀死任意工作;工具或其调用的能力必须遵守 `exec.signal` 并达到静止状态。因此声明 `timeoutMs` 的含义是「此工具对 `exec.signal` 是协作式的」,插件 README 将此作为契约声明。 +这是一个协作式截止。它不会通过竞争工具 promise 来杀死任意工作;工具或其调用的能力必须遵循 `exec.signal` 并达到静止状态。因此声明 `timeoutMs` 意味着「此工具与 `exec.signal` 协作」,插件 README 将此作为其契约。 -可重建性不需要新的会话事件:`TOOL_TIMEOUT` 就是该调用最终面向模型的 `tool/result`,因此现有会话日志已经记录了下一次模型请求所看到的内容和结构化 `{ name, code }` 错误。 +无需新的会话事件来保证可重建性:`TOOL_TIMEOUT` 是该调用的最终面向模型的 `tool/result`,因此现有会话日志已经记录了下一次模型请求所见的内容和结构化 `{ name, code }` 错误。 ### 现有工具适配 -`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent 的形态,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。 +`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent 的形状,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。 -`dsh-web-fetch-local` 保留一个配置的提供方级 `timeoutMs`,作为直接调用 `ctx.web.fetch()` 的调用方和配置错误部署的大资源兜底;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常获胜。 +`dsh-web-fetch-local` 保留一个配置级别的 `timeoutMs` 作为大型资源兜底,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误的部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,而外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜出。 `bash` 保持当前的后端超时路径。`dsh-tool-bash` 继续暴露 `timeoutMs` 和 `run_in_background`;`dsh-bash-local` 继续使用 `@deepseek-ai/dsh-timeout` 处理 `BASH_TIMEOUT`;钩子桥接继续调用 `runHook()` 并通过 `ctx.bash` 传递 `timeoutMs`。这保持了前台/后台/钩子行为的稳定。 -`read`、`write`、`edit`、`todo_write`、`bash_output` 和 `bash_kill` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们要么只能尽力而为,要么没有必要。 +`read`、`write`、`edit`、`todo_write`、`bash_output` 和 `bash_kill` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们而言要么只能尽力而为,要么没有必要。 -未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现,无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对此类工具造成问题,bash seam 可以后续添加调用方拥有截止时间的模式;那不在本次范围内。 +未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现而无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对这类工具造成问题,bash seam 可以后续添加调用方自有截止模式;这不在本次范围内。 ## 曾考虑的替代方案 -**将插件命名为 `tool-timeout`。** 字面的 RFC 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该守卫要求每个匹配项注册一个面向模型的工具。此插件不注册任何工具——它是 `tools/execute` 的包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制一个误导性的启动条目。包名为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 分组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 +**将插件命名为 `tool-timeout`。** 字面的 RFC 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包(package)为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 -**仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的原有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。对 web 类工具而言它不够好,因为每个新的支持超时的工具都必须自行选择校验、上限语义、文档、快照和分类。插件集中了策略和分类,同时让每个工具的 schema 专注于业务输入。 +**仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的既有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。它对 web 类工具不利,因为每个新的支持超时的工具都必须自行选择校验方式、上限语义、文档、快照和分类。插件集中了策略和分类,让每个工具的 schema 专注于业务输入。 -**立即将所有超时策略移出 bash-local。** 长期更干净:bash-local 将变为纯子进程执行器,所有调用方拥有自己的截止时间。作为第一步它不合适,因为钩子直接调用 `ctx.bash`,而 bash 模型工具有前台/后台语义,这与工具调用的生命周期不同。保留 `BASH_TIMEOUT` 维持了这些路径的稳定,同时工具调用超时在更简单的工具上验证自身。 +**立即将所有超时策略移出 bash-local。** 长期来看更干净——bash-local 将成为纯子进程执行器,所有调用方自行管理截止时间。但作为第一步不合适,因为钩子直接调用 `ctx.bash`,且 bash 模型工具的前台/后台语义与工具调用生命周期不同。保留 `BASH_TIMEOUT` 维持了这些路径的稳定,同时让工具调用超时在更简单的工具上先行验证。 -**为所有工具使用全局默认预算。** 方便,但会让工具作者意外:任何偶然运行超过全局预算的工具在插件加载后就会开始失败。逐工具声明的预算使采纳成为有意识的行为。 +**为所有工具使用全局默认预算。** 方便,但会让工具作者意外:任何偶然运行超过全局预算的工具在插件加载后就会开始失败。逐工具声明预算使采纳成为有意的行为。 -**暴露面向模型的 `timeout_ms` 覆盖参数。** Claude Code 的 `WebFetch`/`WebSearch` 和 Codex 的 web 工具将超时排除在模型调用形态之外。模型覆盖会使超时成为提示词语义的一部分,并迫使 `timeout-policy` 引入 schema/参数剥离规则。Web 超时仅作为部署策略。 +**暴露面向模型的 `timeout_ms` 覆盖参数。** Claude Code 的 `WebFetch`/`WebSearch` 和 Codex 的 web 工具将超时排除在模型调用形状之外。模型覆盖会使超时成为提示词语义的一部分,并迫使 `timeout-policy` 引入 schema/参数剥离规则。Web 超时仅作为部署策略。 -**让 `timeout-policy` 自行匹配工具参数。** 类似「当 `bash.run_in_background` 为 true 时禁用超时」的规则引擎会使策略插件了解工具特定的参数语义。通过不将 bash 迁移到工具调用超时来避免此问题。 +**让 `timeout-policy` 自行匹配工具参数。** 诸如「当 `bash.run_in_background` 为 true 时禁用超时」之类的规则引擎会让策略插件了解工具特定的参数语义。通过不将 bash 迁移到工具调用超时来规避此问题。 -**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这不可行,因为截止时间的生命周期将跨越两个独立的 waterfall:需要 call-id 映射、在每个 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。 +**使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这样做的问题是截止时间的生命周期会跨越两个独立的 waterfall:需要 call-id 映射、在每条 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。 -**使用 `Promise.race` 为非协作式工具强制超时。** 否决,原因与超时库 RFC 相同:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。 +**使用 `Promise.race` 对非协作工具强制超时。** 与超时库 RFC 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。 ## 后果 -- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是窄的:包装注册表分发,而非替代 pre 门禁或 post 结果策略;基础 `next()` 是「分发并规范化」,因此包装器永远不会看到原始的工具抛出。 -- 多个 `tools/execute` 监听器通过普通的 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。组合超时与未来的重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 -- 按声明加入是一个有意的配置错误风险:工具可以声明 `timeoutMs` 但不遵守 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作式;web 工具在已经转发信号的工具上证明了这一模式。 -- 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍然是 bash 和钩子使用的 bash 后端超时。 -- 与字面提案的偏差,按已实现 RFC 规则记录:插件包名为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`),信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者),逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置)而非在此插件的配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在「## 决策」中描述。 +- `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是狭窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到原始的工具抛出。 +- 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 +- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 +- 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。 +- 与字面提案的偏差,按 implemented-RFC 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文「决策」一节中描述。 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 7f9d0625f3..3253751754 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-agent-scope-contexts.md: f238d58d90413d36e81b34c1a2c94e1291e889de -2026-07-08-agent-scope-contexts.zh.md: dfade19709ccbf206054f414882ecff114ac1e44 +2026-07-08-agent-scope-contexts.zh.md: 0f4de12e782fc72d3761b6d46cd953ab4650654d diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index dfade19709..0f4de12e78 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -6,25 +6,25 @@ Status: implemented ## 问题 -一个应用需要在多个 agent(智能体)之间共享基础设施,同时让每个 agent 拥有自己的工具、prompt 贡献、策略和监听器。共享的适配器、持久化和用户界面属于部署层面;而一个人设、工具变体或监听器往往只属于某一个 agent。 +一个应用需要在多个 agent(智能体)之间共享基础设施,同时让每个 agent 拥有自己的工具、提示词贡献、策略和监听器。共享的适配器、持久化和用户界面属于部署层面;而 persona、工具变体或监听器往往只属于某一个 agent。 -为每个 agent 建立独立的服务图会重复共享基础设施。一个全局注册图则有相反的问题:某个 agent 的专属贡献可能泄漏到无关的 agent 中。贡献者需要一种普通的注册机制,既能决定谁能看到一项贡献,又能决定何时清理它。 +为每个 agent 建立独立的服务图会重复共享基础设施。使用一个全局注册图则有相反的问题:某个 agent 特有的贡献可能泄漏到无关的 agent 中。贡献者需要一种普通的注册机制,既能决定谁可以看到某项贡献,又能决定何时清理它。 -该机制还需要一个发布边界。agent 在其本地世界完整之前不得变为可见,而拆除过程必须保留该世界直到最终工作停止。 +该机制还需要一个发布边界。agent 在其本地世界构建完成之前不得变为可见,拆除时也必须保留该本地世界直到最终工作停止。 ## 决策 -每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有该贡献的上下文进行注册;感知作用域的服务将部署全局注册与恰好一个匹配的 agent 层组合;操作从其真实 agent 选择该层;该层在 agent 完整的已发布生命周期内存在。 +每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的 context 进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 -Cordis 是 SDK 底层的插件框架。Cordis **上下文(context)** 是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../cordis-primer.md)对框架有更详细的说明。 +Cordis 是 SDK 底层的插件框架。Cordis **context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该 context。[Cordis 入门](../../../cordis-primer.md)对该框架有更详细的说明。 -对大多数贡献者而言,完整的契约是四条规则: +对大多数贡献者而言,完整契约是四条规则: | 问题 | 规则 | |---|---| -| 在哪里为某个 agent 注册行为? | 通过 `agent.ctx` 调用普通的注册 API | -| 某个 agent 的操作能看到什么? | 部署全局加上该 agent 的层,使用所属服务的合并规则 | -| 哪些作用域监听器会运行? | 无作用域监听器加上为该操作的 agent 注册的监听器 | +| 在哪里为某个 agent 注册行为? | 通过 `agent.ctx` 调用普通注册 API | +| 某个 agent 的操作能看到什么? | 部署全局加上该 agent 的层,按所属服务的合并规则 | +| 哪些作用域监听器会运行? | 无作用域监听器加上为该操作所属 agent 注册的监听器 | | 该层存在多久? | setup 在发布前完成;dispose 保留该层直到工作达到静止 | 作用域是扁平的。解析永远不会遍历父级或兄弟作用域,生命周期所有权也不意味着注册继承。 @@ -43,20 +43,20 @@ flowchart LR agentBLayer --> agentBView ``` -缺失的交叉边就是隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因为父级拥有子级的生命周期就进入子级。 +缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 RFC](2026-07-12-agent-scope-runtime-design.md) 解释了实现与正确性推理。[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 RFC](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 -通过普通插件上下文进行的注册是部署全局的,随该插件 dispose。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域 dispose。 +通过普通插件 context 进行的注册是部署全局的,随该插件一起 dispose(资源释放)。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域一起 dispose。 | 注册来源 | 默认可见性 | 随谁 dispose | |---|---|---| -| 普通插件上下文 | 每个符合条件的 agent 视图 | 注册插件 | +| 普通插件 context | 每个符合条件的 agent 视图 | 注册插件 | | `agent.ctx` | 仅该 agent 的视图 | agent 作用域 | -工具、prompt 段落与变量、工具限制、守卫和作用域事件监听器都采用此契约。同名的本地值通常对该 agent 遮蔽同名的全局值;每个所属服务自行记录例外与合并行为。 +工具、提示词段落与变量、工具限制、守卫以及作用域事件监听器都遵循此契约。命名的本地值通常对该 agent 遮蔽同名全局值;各所属服务文档会说明例外与合并行为。 普通贡献者的模式是在 agent setup 期间注册完整的本地世界: @@ -89,35 +89,35 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通的插件和服务。其契约仅限组合:通过强制转换或内部注册表调用来驱动或发布正在构建的 agent 是不受支持的。 +setup 接收一个完整的受信 Cordis context,因此可以组合普通插件和服务。其契约仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 ### 操作选择视图 注册来源与操作主体是两个独立的事实。通过 `agent.ctx` 调用服务决定的是新注册归属何处,并不将后续读取绑定到该 agent。 -工具查找与执行接收其服务的 agent。prompt 组装接收正在构建请求的 agent 的组装上下文。事件分发接收其领域主体。这使共享服务实例可在多个 agent 间复用,同时让每个操作的视图保持显式。 +工具查找与执行接收其所服务的 agent。提示词组装接收正在构建请求的 agent 的组装上下文。事件分发接收其领域主体。这使共享服务实例可在多个 agent 间复用,同时让每个操作的视图保持显式。 只有采纳了作用域契约的服务才会解析 agent 层。`agent.ctx` 不会自动改变任意 Cordis 服务调用的行为。 ### 作用域事件将路由与事件数据分离 -关于 Agent A 的事件通常到达无作用域监听器和 A 作用域监听器,而不到达 B 作用域监听器。没有 agent 主体的事件只到达无作用域监听器。 +关于 Agent A 的事件通常到达无作用域监听器和 A 作用域监听器,而不到达 B 作用域监听器。没有 agent 主体的事件仅到达无作用域监听器。 在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 -以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。 +以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册 context。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。 ### 创建最后发布,dispose 最后撤销 `ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。 -可选的创建信号仅在 create 或 resume 挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式的 dispose 权。 +可选的创建信号仅在 create 或 resume 挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。 -如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;所有失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 +如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 -`AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷新,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 +`AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷写,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 -调用方的 Cordis 上下文和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。 +调用方的 Cordis context 和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。 ```mermaid flowchart TB @@ -139,25 +139,25 @@ flowchart TB ## 安全与权限是非目标 -agent 作用域组合的是受信的同进程注册。它不沙箱化插件,不定义父到子的权限格,不在创建时冻结授权,也不保证子级不能做超出父级的事。 +agent 作用域组合的是受信的同进程注册。它不沙箱化插件、不定义父到子的权限格、不在创建时冻结授权、也不保证子级不能做超出父级的事。 -父级可以拥有一个可见工具比自身更宽的子级,因为生命周期所有权不捐赠也不封顶注册。持有 Cordis 上下文的插件同样运行在同一进程中,可以直接调用可用服务。 +父级可以拥有一个可见工具比自身更广的子级,因为生命周期所有权不赠予也不限制注册。持有 Cordis context 的插件同样运行在同一进程中,可以直接调用可用服务。 -需要非升级保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式的未来授权 API、以及通用的能力/输出/终止标签均不在本决策范围内。 +需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。 ## 曾考虑的替代方案 -被否决的设计要么将可见性与清理分离,要么只覆盖一个注册族,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。 +被否决的设计要么将可见性与清理分离,要么只覆盖一类注册,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。 -### 向每次注册传递 agent 选项 +### 向每个注册传递 agent 选项 -类似 `tools.register(definition, { agent })` 的 API 在每个注册表中重复作用域管道,并允许可见性所有权与清理所有权漂移。通过 `agent.ctx` 注册使两个事实跟随同一个 Cordis 效果所有者。 +类似 `tools.register(definition, { agent })` 的 API 在每个注册表中重复作用域管道,且允许可见性所有权与清理所有权漂移。通过 `agent.ctx` 注册使两个事实跟随同一个 Cordis effect owner。 ### 过滤事件但保持注册表全局 -监听器过滤能阻止错误的钩子运行,但无法限定工具 schema、可执行查找、prompt 段落、变量或其他已注册数据的作用域。agent 本地组合仍需临时的全局变更。 +监听器过滤可以阻止错误的钩子运行,但无法限定工具 schema、可执行查找、提示词段落、变量或其他已注册数据的作用域。agent 本地组合仍需临时的全局变更。 -### 为每个 agent 创建一个服务图 +### 为每个 agent 创建独立的服务图 所需的视图是共享部署服务加上一个本地注册层。每 agent 一个图会重复适配器,并使共享持久化、提供方注册表和应用启动复杂化。 @@ -167,6 +167,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件, ## 后果 -贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作上选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,teardown 保留本地行为直到工作停止。 +贡献者使用一种熟悉的模式:通过插件 context 注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 -代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等于权限,subagent 组合控制作为独立功能存在,而非隐藏在作用域语义中。 +代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限,subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml index c48d3db510..b0b2ec8727 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml @@ -3,4 +3,4 @@ # 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: 0bb2a2f2e307b8f23a3a9ca98edb2a2d3b5df0a8 -2026-06-14-acp-agent-client-protocol.zh.md: 19744cb9f4d4b675586d4327a1da423fdea21b77 +2026-06-14-acp-agent-client-protocol.zh.md: 7bb0e066572150c9c8fc0b94de1d5bf2d69527ee diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md index 19744cb9f4..7bb0e06657 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md @@ -1,4 +1,4 @@ -# RFC:ACP(Agent Client Protocol)支持——从外部编辑器驱动编码 agent +# RFC:Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent [English](2026-06-14-acp-agent-client-protocol.md) | 中文 @@ -6,54 +6,54 @@ Status: implemented ## 问题 -harness 最初只通过 readline 循环暴露 agent(智能体)。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成状态、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的情况下取消某个对话。ACP 将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 +harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 -桥接层必须保持 harness 既有的职责边界。它不能依赖具体的 agent loop(智能体循环)、绕过工具注册表、在编辑器中执行 shell 命令,或发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。 +桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。 ## 决策 -`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编程接口级服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不改变 agent loop,也不是能力 seam 的实现。 +`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。 桥接层实现以下稳定的会话路径: -- `initialize` 协商协议版本,声明支持 text 与 `resource_link` prompt,并声明 `loadSession`。 -- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回组合支持的配置选项。 -- `session/load` 在构造 agent 之前,先用持久化元数据校验请求的 cwd;在异步恢复期间预留 id;将 user/assistant/tool 事件作为 ACP update 回放;并报告恢复后的 config-option fold。 -- `session/prompt` 接受 text 和 resource link,拒绝不支持或空的内容,每个会话只允许一个 in-flight prompt,并在该 prompt 所属的 `turn/end` 时结算。错误 turn 拒绝 RPC;其他关闭 turn 的原因通过一个全覆盖的 ACP stop-reason codec 映射。 +- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的 prompt,并声明 `loadSession` 能力。 +- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回由组合层支持的配置选项。 +- `session/load` 在构造 agent 之前校验请求的 cwd 与持久化元数据是否一致,在异步恢复期间保留 id,将用户/助手/工具事件作为 ACP update 回放,并报告恢复后的 config-option 折叠结果。 +- `session/prompt` 接受文本和 resource link,拒绝不支持的或空的内容,每个会话同时只允许一个 in-flight prompt,并在该 prompt 所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。 - `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的 prompt。 -工具调用的呈现仍由工具自身负责。工具的 `presentCall` 和 `presentResult` 返回 `generic`、`terminal` 或 `diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 约定;harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境变量清理、任务归属和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中没有硬编码的工具名分支。 +工具调用的展示仍由工具自身负责。工具的 `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 中「每次工具调用都询问」的策略。一个带有 call id 的、针对桥接层所属 agent 的 `approval/request`,会变成该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。非本桥接层的请求或无 call id 的请求走委托路径;answerer 缺失或失败时保持 fail-closed。决定是否询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。 +权限处理是 [user-approval seam](2026-07-06-approval-seam.md) 上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 保持 fail-closed。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。 -当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。出厂的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一个审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验,并写入两个所属旋钮事件。在 open turn 期间的切换立即追加;idle 状态下的切换在响应中叠加,并在下一次 `agent/prompt-submit` 时锚定,位于请求组装之前。在此之前它仅存于内存,因此崩溃后恢复的是持久化的 fold。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 仍为连接级。 +当 `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、选项描述和自定义回答覆盖语义均被保留。 +桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述与自定义回答覆盖语义均被保留。 -生命周期归属是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的 prompt、并行 dispose 每个 handle、等待循环静默和持久化刷盘,然后移除记录。流式通知失败被隔离,已消失的客户端无法破坏 agent turn。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。 +生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的 prompt,并行 dispose 所有 handle,等待循环静默与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。 -精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);package README 是运维契约。 +精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);package README 是操作契约。 ## 曾考虑的替代方案 -**在 `tools/execute` 前置一个监听器,对每个 ACP 所属调用都询问权限**:否决。这会把权限策略硬编码进 UI 桥接层,即使没有策略要求也会询问,且无法服务执行开始后才产生的审批请求。共享的用户审批 seam 将机制、询问策略和 UI answerer 分离。 +**在 `tools/execute` 监听器前置一层,对每个 ACP 所属调用都询问权限**:否决。这会将权限策略硬编码到 UI 桥接层,即使没有策略要求也会询问,且无法服务于执行开始后才产生的审批请求。共享的 user-approval seam 将机制、询问策略和 UI answerer 分离。 -**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、idle 观察和 dispose 是 `dsh-agent` 上的接口级归属操作;UI 插件不需要依赖规则的例外。 +**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、空闲观察与释放是 `dsh-agent` 上的接口级所有权操作;UI 插件不需要依赖规则例外。 -**通过 ACP `terminal/*` 执行 bash**:否决。那会把执行移到 harness 之外,绕过其沙箱、凭证清理、任务归属、cwd 解析和会话日志。终端元数据仅用于呈现。 +**通过 ACP `terminal/*` 执行 bash**:否决。这会将执行移到 harness 之外,绕过其沙箱、凭证清洗、任务所有权、cwd 解析与会话日志。终端元数据仅用于展示。 -**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的旧接口。 +**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的遗留接口。 -**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用归属范围,且与协议传输竞争。应用组合拥有 stdout 纯净性。 +**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用所有权范围,且与协议传输存在竞争。应用组合拥有 stdout 纯净性。 ## 后果 -编辑器可以通过一条 ACP 连接创建、加载、prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 和每会话配置的持久真源。工具呈现与人工回答通道仍是可扩展的插件契约,而非 ACP 特有行为。 +编辑器可以通过一条 ACP 连接创建、加载、提交 prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。 -桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托,以及 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。 +桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。 -idle 状态下的 config 选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到 open turn 之前不具有持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件 turn 封闭且回放安全的代价。 +空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。 ## 验证 -ACP 测试套件覆盖内存协议编解码、创建/加载回放、精确的 prompt 结算、取消竞态、不支持的内容、工具呈现、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/dispose 静默,以及 HMR(热模块替换)清理。快照和 built-bin 测试检验应用组合,真实 API 的 e2e 在无 key 时自动跳过。 +ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的 prompt 结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放静默,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml index 312106acaf..825e4c2ed8 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml @@ -3,4 +3,4 @@ # 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: b96557d2d94711adb2183aa4f5dd8debf39c1de8 -2026-06-14-acp-multi-session.zh.md: 263e292e7161b789b8e06772deb0d2bc896f8de6 +2026-06-14-acp-multi-session.zh.md: 6a9f5e8162d46ed8719164247e22b8b9c5d26c61 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md index 263e292e71..6a9f5e8162 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -1,39 +1,39 @@ -# RFC:在单连接上多路复用并发 ACP 会话 - -Status: implemented +# RFC:在单个连接上多路复用并发 ACP 会话 [English](2026-06-14-acp-multi-session.md) | 中文 +Status: implemented + ## 问题 -一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上维持多个活跃对话。如果桥接层只允许单活跃会话,就不得不额外启动进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台任务 id 都绝不能跨越会话边界。 +一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上保持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。 ## 决策 -ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,供 agent 作用域的回调使用。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待生效的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造重复的 agent;不同 id 可以并发加载。 +ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,用于 agent 作用域的回调。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。 -每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到来时结算;来自已取消的先前轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到单条记录,只调用该 agent 的队列感知取消路径。 +每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。 -权限归属使用同一个反向索引。ACP `approval/request` 应答器仅向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值仅折叠该会话自身的事件,待生效的空闲切换存储在该记录上,直到下一个轮次将其锚定。 +权限归属使用同一个反向索引。ACP `approval/request` 应答器只向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。 -后台 bash 任务携带一个不透明的 owner token,其值等于所属会话的 session id。`bash_output` 和 `bash_kill` 在读取或终止之前,会将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不授予访问权限。归属信息存储在执行器任务上,因此工具插件重载不会擦除它。 +后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`bash_output` 和 `bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。 -连接拆除时清空活跃 map,将每个待结算的 prompt 以取消状态结算,并并行 dispose 所有 `AgentHandle`。每个句柄停止并等待其循环结束,在仍挂载时刷新会话,注销 agent,然后移除会话。拆除操作被 memoize 并在客户端断开与插件 dispose 之间共享。 +连接拆除时清空活跃 map,将每个待处理的 prompt 以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。 ## 曾考虑的替代方案 -**每连接单活跃会话**:否决。它增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。 +**每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。 -**每会话一个 `ctx.extend()`**:否决。子上下文本身并不创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话的归属记录;agent 生命周期由 `AgentHandle` 拥有。 +**每会话 `ctx.extend()`**:否决。子上下文本身不会创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话拥有的记录;agent 生命周期由 `AgentHandle` 管理。 -**以 agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是应当在插件重载后存活的跨边界标识。 +**以 Agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是跨边界的标识,应当在插件重载后仍然存活。 ## 后果 -N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不为每个会话添加一套监听器,因此在长连接期间避免了监听器扇出。 +N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。 -桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于推迟状态。 +桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于延期状态。 ## 验证 -多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离和拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。 +多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离以及拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。 diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml b/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml index b7e263a61a..5aeee85382 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-15-code-mode.md: c64b6d6d8442e60240fa6c849833385ed50d71ff -2026-06-15-code-mode.zh.md: f94ef61dae180bad5ec604b5c7f9552eeecff593 +2026-06-15-code-mode.zh.md: e9ae74f6629f3e34a0e97f0fa532764c70095bba diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md index f94ef61dae..e9ae74f662 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md @@ -1,132 +1,132 @@ # RFC:Code Mode——模型针对工具注册表编写 TypeScript -Status: implemented - [English](2026-06-15-code-mode.md) | 中文 +Status: implemented + ## 问题 -在注册表的原生呈现方式中,agent loop(智能体循环)将每个可见能力作为 JSON Schema 函数定义广播。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每个**中间 `tool-result` 都在下一次请求时重新进入模型上下文。 +在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 -对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都把整个中间结果拖回上下文,无论模型是否需要。 +对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。 -Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单观察:LLM(大语言模型)写代码比发出工具调用更擅长,因为它们见过数百万行真实代码,而见过的人造工具调用 trace 相对很少。模型不再每步发出一个工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只取回它打印或返回的内容——而非所有中间结果。 +Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只策展返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 -工具呈现属于拥有工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位符:Node `worker_threads` 提供独立隔离区、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 现有的信任模型(见§信任姿态)。 +工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/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)。注册表在源头塑造其规范贡献;协作式 prompt 组装的结果仍具权威性,请求头日志记录的正是该返回的呈现。 +1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../../implemented/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`**:每次运行启动一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——不需要 unsafe-acknowledgement 标志——因为 harness 已经提供了 `dsh-bash-local`,后者以严格**更大**的环境权限执行模型编写的任意 shell 命令。 +3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格**更高**的环境权限执行模型编写的任意 shell 命令。 ### 注册表拥有模式 -`ToolRegistry` 获得一个 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 切换(`tools: { mode: code }`)——无需改代码,遵循 no-hardcoded-tunables 约定。 +`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` 是一个保留的呈现传输通道,位于注册和限制层之外;直接 prompt 提供方和组装 waterfall 仍各自负责自己的贡献。 -**与 `toolOrder` 的交互,预先声明:** 如果配置的 `systemPrompt.toolOrder` 命名了原生能力,则在 `mode: 'code'` 下会拒绝所有组装,因为这些名称不在该模式的协议校验范围内。这是正确行为,不是 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。 +**与 `toolOrder` 的交互,预先说明:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。 -**SDK prompt 段落。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段落为作用域内可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 +**SDK prompt 段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 -**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。作用域内的 `tools:sdk` 段落可以在分发前遮蔽全局默认值,监听器可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议可行;没有恢复 pass 会覆盖有意的组合。 +**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 -**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,并将不支持的构造降级为 `unknown`。SDK 以带引号的对象键暴露工具,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。 +**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。 -### run_code 工具与分发桥接 +### run_code 工具与分发桥 -在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以便分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是归一化的外层结果。其 `execute(args, exec)`: +在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: -1. **构建绑定。** 一个 run 作用域的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对其参数做 JSON 归一化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己的不可变执行身份,并遍历完整的工具流水线。 -2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 作用域的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 -3. **静默后结算。** 运行时结算后,桥接 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后没有子调用可以追加。 +1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 +2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 +3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 -**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的邻接性,而一个程序可以产生多个上下文。支持它需要一个复数通道或循环级别的子分发缓冲区。 +**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的相邻性,而一个程序可以产生多个 context。支持它需要一个复数通道或循环级别的子分发缓冲区。 -**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要逐工具的并发安全元数据。 +**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。 -**呈现。** `run_code` 的渲染意图按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一段程序文本;`presentResult` → 一个 `generic` 卡片,内容为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 +**呈现。** `run_code` 的 render intent 按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 ### 可观测性:`tool/code-dispatch` -每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具身份、归一化参数和结果摘要。它不进入模型历史,但可供持久化和 UI 使用。追加发生在打开的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 +每次子分发追加一个仅日志的 `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')`)加词汇: +`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<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。 -- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,以 `error` 字段解析。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端 rejection。 +- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,都解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝。 - `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` -- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——正交的结果按[防御性模式](../../../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`)。 +- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——按[防御性模式](../../../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 时查找可选的运行时,因此原生模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 后面替换实现,配对相应的 SDK 生成器。 +请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。 -### worker 线程运行时 +### worker-thread 运行时 -`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包。每次 `run()`: +`@deepseek-ai/dsh-code-runtime-worker`,`packages/code-runtime/` 组的第二个包(package)。每次 `run()`: -1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。Strip-only 模式拒绝不可擦除的语法(`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` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。 -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` 限制总经过时间,包括未完成的等待。到期、取消和完成都会终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限都是经过校验的配置。 -6. **Dispose 至静默**:服务自身的 disposal 终止进行中的 worker 并*等待*它们退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。 +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` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。 +4. **通过 message port 桥接绑定**: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` 约束总经过时间,包括未解析的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。 +6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。 ### 信任姿态 -worker 运行时提供的是封闭隔离,而非安全边界:模型代码可以触及 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门控,并额外提供空环境、堆限制、独立隔离区和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 +worker 运行时提供的是隔离,而非安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 ### 模型看到的内容 -SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时 catch 被 reject 的工具调用,并仅返回或打印应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可以与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 +SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 ## 后果 -切换到 `'code'` 的部署必须更新任何仅原生的 `toolOrder`。组装监听器负责维护任何被重写的协议表面的完整性。子分发保持序列化,桥接不会传播逐调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。 +切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,桥不传播每次调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。 ## 测试 -- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、敌对绑定流量、空环境、structured-clone 回退、输出上限和 disposal 至静默。一个 built-package 测试在纯 Node 下运行 worker 入口。 -- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、作用域可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 归一化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。 -- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、生成的文件和精选的回答。 -- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段落、头部工具列表、分发事件和结果卡片。 +- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、恶意绑定流量、空环境、structured-clone 回退、输出上限和 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。 +- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。 +- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、结果文件和策展后的回答。 +- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段、请求头工具列表、分发事件和结果卡片。 ## 曾考虑的替代方案 -**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,且依赖监听器顺序。模型被提供哪些工具、以何种表示,是注册表的单一关注点:原生 schema 和 SDK 是同一可见存储的两种投影。 +**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。 -**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立隔离区、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。 +**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。 -**对原生工具调用做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志表面替换很容易添加,但仍然每次调用付出一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 +**在原生工具调用上做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 -**循环中的并行原生分发。** 往返开销的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策使两者兼容:当元数据就绪时,原生并行分发和逐工具绑定并行化一起解锁。 +**循环中的并行原生分发。** 往返成本的另一个答案;仍是有效的未来工作(open TODO),仍被并发安全元数据阻塞,且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容:当元数据就绪时,原生并行分发和每工具绑定并行化一起解锁。 -**始终排他(忠实于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是理想的,强迫每次编辑都通过程序会加重常见场景的负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用而不强加。 +**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。 -**逐工具可见性层级(此工具原生,彼工具仅 code)。** 推迟:它需要逐工具元数据和 `'native' | 'code' | 'both'` 不具备的呈现拆分,且其设计依赖于模型在 `'both'` 下如何分配使用的证据。 +**每工具可见性分层(此工具 native,彼工具 code-only)。** 推迟:它需要每工具元数据和 `'native' | 'code' | 'both'` 不提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。 -**SDK 中的消毒标识符别名**(`my-tool` → `my_tool`,Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名冲突逻辑;模型处理 `tools["my-tool"](…)` 没有问题。 +**SDK 中的清洁化标识符别名**(`my-tool` → `my_tool`,Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名碰撞逻辑;模型能正常处理 `tools["my-tool"](…)`。 -**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。MVP 否决:跨调用状态对会话日志不可见,破坏了每个请求是日志纯函数的可重建性保证;每次 run 全新保持了这一点。内核风格后端在未来仍可通过 seam 表达,配合自己的日志方案。 +**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。在 MVP 中否决:跨调用状态对会话日志不可见,破坏了「每个请求是日志的纯函数」这一可重建性保证;每次 run 全新保持了这一点。内核风格的后端在未来仍可通过同一 seam 表达,配合自己的日志方案。 ## 风险 -**Worker 不是硬安全边界。** 有意为之且已文档化(见§信任姿态):姿态等同于现有 bash 工具,封闭隔离超过它,门控使用相同的 seam。需要更多的部署需要未来的 `isolation: 'container'` 后端——作为 seam 的设计扩展跟踪,而非本设计的 TODO。 +**Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,隔离程度超过它,门禁使用相同的 seam。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪,而非本设计的 TODO。 -**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数后面,且 `amaro`/`sucrase` 是 API 变动时的即插即用替代品。可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。 +**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。 -**SDK 的 prompt 开销,尤其在 `'both'` 下。** `.d.ts` 可以与它补充的原生 schema 相当大;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话开销;mode 是逐部署的;本 RFC 不做无条件节省的声明。何时偏好哪种模式的量化指导明确是上线后的学习。 +**SDK 的 prompt 成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 RFC 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 -**注册表范围增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥接和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 来约束:所有基底形状的东西都在 `ctx.codeRuntime` 后面。 +**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 之后。 -**Structured-clone 值可以超出 JSON。** 因此工具绑定在分发前对参数做 JSON 归一化,确保每个执行的调用都可以被记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。 +**Structured-clone 值可能超出 JSON。** 因此工具绑定在分发前对参数做 JSON 规范化,确保每次执行的调用都可记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。 -**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少了往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的相同并发安全元数据绑定。 +**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。 -**预算计量读取事件循环,而非标志。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending dispatch 无法暂停它」)对敌对程序是承重的。两侧都有单元测试(带 pending decoy dispatch 的热循环在 `computeMs` 时死亡;idle-on-slow-binding 存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过。 +**预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)对恶意程序是承重的。两侧都有单元测试(带 pending 诱饵分发的热循环在 `computeMs` 处死亡;在慢绑定上空闲的程序存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。 diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index 6aac0209ec..f5eae41ba4 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml @@ -3,4 +3,4 @@ # 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: c2d3aa679599b1129a19b9082b9254ecf3103f12 -2026-06-17-filesystem-tool-schemas.zh.md: b13274c41da244d7d2a5fe6ff2064d8d5e0a9b42 +2026-06-17-filesystem-tool-schemas.zh.md: cd504a37d5ee25f6634b651d30099afe5acd6495 diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index b13274c41d..cd504a37d5 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -1,24 +1,24 @@ -# RFC:文件系统工具 schema——面向模型的读/写/编辑形状 - -Status: implemented +# RFC:文件系统工具 schema——面向模型的读/写/编辑接口形状 [English](2026-06-17-filesystem-tool-schemas.md) | 中文 +Status: implemented + ## 问题 -[文件系统能力 seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam(`ctx.fs`)、包拆分(`dsh-fs`、`dsh-fs-local`、`dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及 read-before-write/edit 检查所依赖的 observed-file/stale-version 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) 两份 RFC 随后将该策略从 `ctx.fs` 移到了 `dsh-fs-policy` 插件的 `fs/*` 事件门上。第一版文件系统工具交付剩余的决策是面向模型的 schema 表面:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 +[文件系统能力 seam RFC](../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 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFC 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 -schema 应当足够小,能在 `dsh-tool-fs` 的首次实现中完成;同时又足够稳定,使未来的本地/远程/沙箱文件系统后端不会引起面向模型的接口变动。它还应避免从参考系统照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有表面。 +该 schema 应足够小,以便在 `dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统中照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 RFC 为原型选择最小的共有接口。 ## 决策 -`@deepseek-ai/dsh-tool-fs` 在第一版文件系统工具套件中暴露以下三个面向模型的工具: +`@deepseek-ai/dsh-tool-fs` 在首个文件系统工具套件中暴露以下三个面向模型的工具: -| Tool | 我们的 schema | Claude Code | OpenCode | 说明 | 纳入原型 | +| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype | |---|---|---|---|---|---| -| `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 下需要先有一次观测(任何窗口化的 read 都算)。 | 是 | +| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES | +| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES | +| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES | schema 使用 snake_case 字段名(`file_path`、`old_string`、`new_string`、`replace_all`),与 Claude Code 及现有 DeepSeek Harness 工具 schema 示例保持一致。消费方包将这些面向模型的名称转换为 `ctx.fs` 调用和 `fs/*` 事件分发。 @@ -26,47 +26,47 @@ schema 使用 snake_case 字段名(`file_path`、`old_string`、`new_string` ### `read` -`read` 检查一个 UTF-8 文本文件并返回带行号的内容。 +`read` 检视一个 UTF-8 文本文件并返回带行号的内容。 参数: - `file_path: string`——必填。要读取的路径,由 `ctx.fs` 解析。 - `offset?: number`——可选。返回的第一行,从 1 开始。默认为第一行。 -- `limit?: number`——可选。返回的最大行数。默认值和上限是 `dsh-tool-fs` / `ctx.fs` 的实现细节。 +- `limit?: number`——可选。返回的最大行数。默认值与上限是 `dsh-tool-fs` / `ctx.fs` 的实现细节。 -首次实现的非目标: +首次实现不涉及的内容: -- 不支持 PDF `pages` 参数。 -- 不支持图片或多模态文件读取。 -- 不通过 `read` 列出目录;如有需要,目录列表将作为单独的未来工具。 +- 无 PDF `pages` 参数。 +- 无图片或多模态文件读取。 +- 不通过 `read` 列出目录;如有需要,目录列表将作为单独的后续工具。 ### `write` -`write` 创建或完全替换一个 UTF-8 文本文件。 +`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。 +在默认 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` 暴露为面向模型的参数。stale-version 检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。 +schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面向模型的参数暴露。过期版本检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。 ### `edit` -`edit` 通过替换字面文本来更新一个已有的 UTF-8 文本文件。 +`edit` 通过替换字面文本来更新已有的 UTF-8 文本文件。 参数: - `file_path: string`——必填。要编辑的路径,由 `ctx.fs` 解析。 - `old_string: string`——必填。要替换的字面文本。首次实现中空字符串无效。 -- `new_string: string`——必填。字面替换文本;空字符串表示删除匹配项。 +- `new_string: string`——必填。字面替换文本;空字符串表示删除匹配内容。 - `replace_all?: boolean`——可选。默认为 false。为 false 时,`old_string` 必须恰好匹配一处。 -`edit` 要求同一执行上下文对该文件有过一次先前观测(任何窗口化的 read 都算——授权依据是版本新鲜度,而非全文查看要求),或该上下文对该文件有过先前的 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 强制执行。 +`edit` 要求同一执行上下文先前对该文件有过一次观测(任何窗口化的 read 都算——授权基于版本新鲜度,而非全文查看要求),或该上下文先前对该文件做过 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 负责执行。 -首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,后端可以自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。 +首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和过期版本的语义。 ## 结果形状 @@ -74,17 +74,17 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 暴露为 默认原生投影: -| Tool | `tool-fs` 消费的结构化 `ctx.fs` 结果 | 默认模型投影 | +| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection | |---|---|---| -| `read` | 返回的行、返回行数、总行数、目标显示路径、文件版本、部分视图标志 | 带行号的文本加分页脚注 | -| `write` | create/update 操作、目标显示路径、新文件版本 | 简洁的 create/update 成功文本 | -| `edit` | 替换次数、replace-all 标志、目标显示路径、新文件版本 | 简洁的 edit 成功文本 | +| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer | +| `write` | create/update operation, target display path, new file version | concise create/update success text | +| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text | -结构化结果不重复模型参数(如 `file_path`、`old_string` 或 `content`),除非后端已将其解析为新信息(如 `displayPath`、`targetKey` 或新版本)。token 感知的截断属于模型投影的职责,不属于后端的规范结果。 +结构化结果不会重复模型参数(如 `file_path`、`old_string` 或 `content`),除非后端已将其解析为新信息(如 `displayPath`、`targetKey` 或新版本)。面向 token 的截断属于模型投影的职责,而非后端规范结果的一部分。 -## 延后 +## 延后事项 -以下内容被明确排除在首版文件系统 schema 之外: +以下内容被明确排除在首次文件系统 schema 实现之外: - 面向模型的 `expected_hash`、`expected_version` 或 `create_only` 参数。 - 目录列表、glob、grep 和搜索工具。 @@ -95,18 +95,18 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 暴露为 ## 测试 -schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒绝、`replace_all` 默认值、snake_case 字段名、描述文本中对观测策略的说明,以及根插件套件注册;集成测试通过 `ctx.tools.execute()` 对真实的 `dsh-fs-local` 提供方执行全部三个工具,并验证模型参数被正确转换为预期的 `ctx.fs` 调用和 `fs/*` 分发。 +schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒绝、`replace_all` 默认值、snake_case 字段名、描述文字中对观测策略的说明,以及根插件套件注册;集成测试通过 `ctx.tools.execute()` 对真实的 `dsh-fs-local` 提供方执行全部三个工具,并验证模型参数被正确转换为预期的 `ctx.fs` 调用和 `fs/*` 分发。 ## 曾考虑的替代方案 -- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端自行掌控精确匹配、重复匹配、行尾和 stale-version 语义。 -- **camelCase 参数名(OpenCode 风格)**:snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开表面。 -- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。stale 检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。 +- **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 字段使实现保持聚焦,但用户可能很快提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非在初始 schema 上叠加重载。 +**首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非对初始 schema 的重载。 -**v1 没有显式的面向模型 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:stale 检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非来自模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非通过模型提供的版本字段。 +**v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:过期检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。 -**命名成为公开表面。** 一旦发布,将 `file_path` 改为 `filePath` 或将 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选定 snake_case 并将其视为稳定的面向模型契约。 +**命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选择 snake_case,并将其视为稳定的面向模型的契约。 diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml index 7f9212b3d3..c8ce15293a 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml @@ -3,4 +3,4 @@ # 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: cab89aa690c2068399ea5429a9c467410c744ce8 -2026-06-18-acp-terminal-and-tool-rendering.zh.md: 5a545b7a53f0814bc0e6071430c367047dc83f7f +2026-06-18-acp-terminal-and-tool-rendering.zh.md: 4047c493e63ac23f758de718616fd1f4bb29f7d4 diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md index 5a545b7a53..4047c493e6 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md @@ -1,48 +1,48 @@ -# RFC:丰富的 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 - -Status: implemented +# RFC:富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 [English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文 +Status: implemented + ## 问题 -ACP 桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 展示](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 +ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 -参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格输出和退出状态;纯文本丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏了原始输入,而人类可读的描述保留为卡片上方的独立块。 +参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。 ## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create` -ACP 规范有一个*客户端侧*终端子协议:agent 调用客户端的 `terminal/create`,传入 `{ command, args, cwd, env }`,由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境变量清洗、后台任务所有权、按会话的 cwd)。把执行路由到编辑器会绕过所有这些机制,并将执行分裂为两个后端。 +ACP 规范有一个*客户端侧*终端子协议:agent(智能体)调用客户端的 `terminal/create`(传入 `{ command, args, cwd, env }`),由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。将执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。 研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理: -- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 和 `_meta.terminal_info.{ terminal_id, cwd }`;输出/退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 和 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。 -- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量)发送,由同一个 `_meta.terminal_output` 能力选择。 +- **`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 侧执行的前提下获得终端卡片的唯一途径。 +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: …]`)来恢复结构化退出——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。dispose 不受影响:没有新资源需要清理,因为桥接层从未创建客户端侧终端。 +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 只能看到内容块,而解析是标记发出的精确逆操作,在同一文件中共同演进并由往返测试守护。 +- **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 ` 路径。 +- **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 消费方也会需要的丰富度。 +- **退出信息从渲染文本解析。** 退出信息通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传递结构化退出(纯 `presentResult` seam 看不到后者)。解析是标记发出的精确逆操作,且位于同一文件中;往返测试固定了这对关系,标记格式变更若破坏解析则测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。 +- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方同样需要的丰富度。 -## 不在范围内 / 非目标 +## 超出范围 / 非目标 -文本块基线仍是无能力声明时的默认行为。两个后续工作有意不在此处构建,各自需要独立 RFC:**实时增量流式传输**(`_meta.terminal_output_delta`,在分片到达时发送,需要 `dsh-bash` 上的增量输出 seam),以及**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片、将 `grep` 解析为 `search` 等,回退到终端卡片——仅展示,绝不改变实际执行的内容)。 +文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 RFC:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 211ad897f7..aff86591fb 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-compaction-capability-seam.md: 31b06905924a07a7f0c2af427d8868585966f1a7 -2026-06-18-compaction-capability-seam.zh.md: ef71b3df39f02221f1bd25beb5e026cb056b95a0 +2026-06-18-compaction-capability-seam.zh.md: 1675484ed65e5cd890f420d4bdd1e16e2a95b2ef diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index ef71b3df39..1675484ed6 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -6,37 +6,37 @@ Status: implemented ## 问题 -长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口——模型随即在响应中途截断(`max-tokens`)或质量退化。**压缩(compaction)**是缓解手段:用一段简洁的摘要替换一段较早的历史,保持近期上下文完整。 +长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即截断响应(`max-tokens`)或性能退化。**上下文压缩(context compaction)** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。 -[会话 surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而建的基础设施:它是事件日志之上的链表,带有一个 `surfaceOp: { op: 'replace', start, end }` 操作,专门用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 +[session surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而构建的基础设施:一条建立在事件日志之上的链表,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 -两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——这些与*何时*压缩、*压缩哪段*彼此独立变化。第二,`SurfaceEventType` 是封闭的,只有五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有它们可以携带 `surfaceOp`。因此一个专属的 `compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上放 `surfaceOp`,invariants 插件在运行时也会拒绝。 +两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——它们独立于*何时*以及*压缩哪段范围*而变化。第二,`SurfaceEventType` 封闭为五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上附加 `surfaceOp`,invariants 插件在运行时也会拒绝。 ## 决策 ### 压缩是一个能力 seam,接口与实现分离 -按照[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进: +遵循[能力 seam RFC](../../implemented/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`,拥有完整算法——token 估算(每 token 字符数——`charsPerToken` 配置,默认 4——加逐块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 的摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是兄弟包(或覆写两个 protected 估算/摘要钩子的子类)。 +1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。 +2. **实现** — `@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,拥有完整算法——token 估算(每 token 字符数,即 `charsPerToken` 配置,默认 4,加上每块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 进行摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是同级包(或覆盖两个 protected 估算/摘要钩子的子类)。 3. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 RFC 范围之外,以便 seam 先稳定下来。 -### 契约依赖 `dsh-session` 和 `dsh-llm`——有意的偏离 +### 契约依赖 `dsh-session` 和 `dsh-llm`——有意为之的偏离 -能力 seam RFC 规定接口包「只依赖 cordis」(对 `dsh-bash` 成立,其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义在 `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约无法表达。 +能力 seam RFC 规定接口包"仅依赖 cordis"(对 `dsh-bash` 成立,因为其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义*在* `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约就无法表达。 -这不是耦合异味——而是契约的领域本身。「只依赖 cordis」的指导原则本来就是「接口只依赖契约真正命名的东西,绝不依赖实现」的简写。`dsh-session` 和 `dsh-llm` 本身就是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。 +这不是耦合异味,而是契约的领域所在。"仅 cordis"的指导原则一直是"接口仅依赖契约真正需要命名的东西,绝不依赖实现"的简写。`dsh-session` 和 `dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。 -### 抽象的 `compactIfNeeded` / `compactRegion`,算法在后端 +### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端 -早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,只有 `estimateContentTokens()` 和 `summarize()` 是抽象的。这会把契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端不得不与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——接口则保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()` 和 `summarize()` 是 `protected` 钩子,子后端可以覆写而无需重新实现遍历——但这种分层是后端的私有关注,不是契约的。 +早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,仅 `estimateContentTokens()` 和 `summarize()` 为抽象。这会将契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——并让接口保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()` 和 `summarize()` 是 `protected` 钩子,子后端可以覆盖而无需重新实现遍历——但那是后端的私有关注点,不是契约的。 -`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` 接受**必填**参数(而非最初的全可选形态)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)以及轮次的 abort signal,因此可选性只会在 seam 处引入隐藏默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件已在那里选择实际模型。 +`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` 接收**必需**参数(而非最初的全可选形式)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)和轮次的 abort signal,因此可选性只会在 seam 处引入隐藏的默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件在那里已经选择了实际模型。 -### 自动压缩运行在 `agent/pre-step`,一个专用的 surface 变更 seam +### 自动压缩在 `agent/pre-step` 运行——一个专用的 surface 变更 seam -压缩会变更会话 surface,因此它在步骤开启之前、消息派生之前运行。`agent/request` 仍然是调用配置变换,永远不需要在 surface 变更后重建历史。 +压缩会变更 session surface,因此在步骤开启之前、消息派生之前运行。`agent/request` 保持为调用配置变换,无需在 surface 变更后重建历史。 解决方案是一个专用的循环 seam:**`agent/pre-step`**(`@mode serial`),由循环在系统组装*之后*、步骤开启(`step/start`)*之前*触发: @@ -48,29 +48,29 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -循环在 `agent/pre-step` 之后只派生一次消息。在 `step/start` 之前运行使压缩记录落在任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且 serial 的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。 +循环在 `agent/pre-step` 之后派生一次消息。在 `step/start` 之前运行,使压缩记录位于任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且串行的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。 ### 保留是轮次无关的;工具配对平衡是唯一的结构守卫 -自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:一个工具密集的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,surface 在*一轮之内*就会增长。单独一轮就可能超出窗口(「失控轮次」)——而在下一次模型调用溢出之前能挽救它的唯一时机,就是下一步的 `pre-step` 检查点。如果把压缩限制在轮次的第一步(或更糟,逐字保留整个进行中的轮次),就恰好重新打开了压缩存在的意义所要堵住的那个缺口:harness 会在最需要压缩的时候崩溃。 +自动压缩在**每个**步骤之前触发,而非每轮一次。这对**失控轮次存活至关重要**:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 在*一轮之内*就会增长。单独一轮就可能超出窗口("失控轮次"),而在下一次模型调用溢出之前唯一能挽救的时机是下一步的 `pre-step` 检查点。如果将压缩限制在轮次的第一步(或者更糟,逐字保留整个进行中的轮次),恰好重新打开了压缩存在的意义所要堵住的缺口:harness 会在最需要压缩时崩溃。 -`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小尾部完整 surface 单元,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到截断处工具配对平衡。平衡按 surface 顺序检查,而非日志序列号,因为替换摘要在旧 surface 位置有新的序列号。`compactRegion` 拒绝将工具调用与其结果拆开的边界。进行中的轮次不享有特殊保留。 +`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。 -因此失控轮次的压缩方式与任何其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持逐字。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`),待该步骤关闭后重试。 +因此失控轮次的压缩方式与其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`)并在该步骤关闭后重试。 -**单单元溢出不在范围内,这是有意的。** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点如粘贴的 `user/message`——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地命名了这个边界,而非掩盖它。 +**单单元溢出不在范围内,这是有意为之。** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点(如粘贴的 `user/message`)——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地指出这一点,而非掩盖。 ### 头部锚定:一个自动检查点,始终在头部 -自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,使自动检查点始终只有一个。因此 `shadowedRange` 是位置性的而非数值序列区间:一个更新的摘要序列号可能占据更旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。 +自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,因此只保留一个自动检查点。`shadowedRange` 因此是位置性的而非数值序号区间:一个较新的摘要序号可能占据较旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。 ### 近似收敛不变式 -`resolveConfig` 校验数值参数但**不**基于假想的摘要长度不变式拒绝。收敛是动态的:提供方的输出上限可能被隐藏或外显的推理 token 消耗,模型可能输出不可预测大小的摘要。`maxTokens` 只是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于它遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能把保留尾部推过预算)——这恰好是上面声明的范围外关注点,而非抖动 bug。 +`resolveConfig` 校验数值参数,但**不**基于虚构的摘要长度不变式来拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显式的推理 token 消耗,模型可能生成不可预测大小的摘要。`maxTokens` 仅是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于其遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能将保留尾部推过预算),这恰好是上述范围外的关注点,而非抖动 bug。 -### Surface 替换:`compact/*` 事件仅存于日志;一条 `user/message` 承载摘要 +### Surface 替换:`compact/*` 事件仅存在于日志;一条 `user/message` 承载摘要 -由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,其 `sourceEventSeqs` 覆盖被遮蔽的节点*以及*簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件: +由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的节点*和*簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件: ``` compact/start → log-only. Acquires the lock. @@ -81,47 +81,47 @@ user/message → surfaceOp { op:'replace', start, end }. THE surface mutatio compact/end → log-only. Releases the lock (carries `error` on a recoverable failure). ``` -`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*就是* user 角色的上下文。 +`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。 ### 检查点框架 + 增量合并(后端私有) -基础后端将摘要包装为已建立的检查点上下文,并标记它以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 只承诺一条替换 user 消息承载可能带框架的摘要。 +基础后端将摘要包装为已建立的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 仅承诺一条替换 user 消息承载可能带框架的摘要。 -### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败分类 +### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败的分类 -`compact/start … compact/end` 括号的合理性,按实际承担的工作排序: +`compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序: -1. **可检测的崩溃孤儿 + 来源记录**(首要)。摘要生成是一次慢模型调用,在 `compact/start` *之后*持久化。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先释放)将崩溃窗口从*静默损坏*转化为可检测的孤儿。 -2. **防止并发压缩。** 如果当前轮次持有一个未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是一个重入绊线——抛出的「already in progress」信号意味着真正的 bug。) +1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。 +2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是重入绊线——抛出"already in progress"表示真正的 bug。) 两种失败路径,均有文档记录: -- **崩溃**(循环在摘要生成中途死亡):一个悬空的 `compact/start`,没有关闭者。因为 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,所以完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。 -- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`,surface 不受影响,模型调用继续使用完整历史。 +- **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`,无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,因此完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围内的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。 +- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`,surface 保持不变,模型调用以完整历史继续。 `compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。 -**核心会话修复保持对压缩无感知——这是有意的。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。因为仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。 +**核心 session 修复保持对压缩无感知——这是有意为之。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。由于仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。 ## 曾考虑的替代方案 -- **完整算法作为接口上的具体方法**(只有估算/摘要是抽象的)——早期草案;否决,因为它把契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。 -- **压缩运行在 `agent/request` waterfall(瀑布式事件)上**——早期方案;否决,因为它强制了双重派生,且交给监听器的上下文在结构上无法压缩。专用的 `agent/pre-step` seam 使分层在构造上正确。 +- **完整算法作为接口的具体方法**(仅估算/摘要为抽象)——早期草案;否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。 +- **在 `agent/request` waterfall(瀑布式事件)上执行压缩**——早期方案;否决,因为它强制双重派生,且将监听器上下文交给了结构上无法压缩的对象。专用的 `agent/pre-step` seam 从构造上使分层正确。 - **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。 -- **教导核心轮次修复认识 `compact/*`**——否决:仅日志的孤儿是惰性的,而一个为每个未来 `xxx/start … xxx/end` 插件对打补丁的核心模块,恰好是能力 seam 架构存在的意义所要避免的耦合。 +- **教导核心轮次修复识别 `compact/*`**——否决:仅日志的孤儿是惰性的,为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。 ## 后果 -- **新包**:`packages/compact/compact`(接口)和兄弟包 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。 -- **新循环 seam**:`agent/pre-step`(`@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。 -- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **不受影响**。这些是会话事件而非 cordis `Events`,因此事件分类门禁无需新增条目。 -- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆开步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在,直接复用。 -- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩会将高序列号的替换节点放在更旧范围的*位置*,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次包含不变式原样复用。 -- **接线**:`dsh-compact-basic` 在 `examples/coding-agent` 的 `cordis.yml` 中加载,使 seam 在真实演示中交付(此前未在任何地方加载)。 +- **新包**:`packages/compact/compact`(接口)和同级的 `compact-basic`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。 +- **新循环 seam**:`agent/pre-step`(`@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是对循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。 +- **`SessionEventMap`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 +- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆分步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在并被复用。 +- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩将高序号替换节点放在较旧范围的*位置*上,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次封闭不变式原样复用。 +- **接线**:`dsh-compact-basic` 在 `examples/coding-agent` 的 `cordis.yml` 中加载,使 seam 在真实演示中生效(此前它未被任何地方加载)。 ## 测试 -- **单元测试:** 真实 Loader 和 invariant 插件覆盖整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。 -- **循环测试:** 测试固定每步在 `turn/start` 和 `step/start` 之间有一次 awaited 的 `agent/pre-step`;在那里的 surface 变更落在步骤之外,并出现在单次派生的请求中。 -- **带密钥 e2e:** 真实模型和 bash 会话在降低限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错的摘要调用回放仍是后续工作。 +- **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、收敛失败、`compact/end` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。 +- **循环测试:** 测试固定每步在 `turn/start` 与 `step/start` 之间有一次 awaited 的 `agent/pre-step`;在该处的 surface 变更落在步骤之外,并出现在单次派生的请求中。 +- **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 +- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index ec702a6101..1da7113f51 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-21-subagent-capability-seam.md: 2bed84cd9166e8aa1ad5fa65b3afa44b8a842045 -2026-06-21-subagent-capability-seam.zh.md: a99d0fe894dca485452dd266752785a26815bb8a +2026-06-21-subagent-capability-seam.zh.md: a5917c14141dd06c14b4f45c5f6e4703f0eb661f diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index a99d0fe894..a5917c1414 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -1,74 +1,74 @@ # RFC:Subagent 能力 seam -Status: implemented - [English](2026-06-21-subagent-capability-seam.md) | 中文 -> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([按会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外 `dsh-subagent-acp` 后端([其 RFC](2026-06-22-acp-subagent-backend.md))。 +Status: implemented + +> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 RFC](2026-06-22-acp-subagent-backend.md))。 ## 问题 -harness 有一个长期搁置的 subagent seam:一个 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)):创建选项引用父 agent(fork = 用父会话的事件日志为子会话播种;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅统一工作。本 RFC 实现该 seam;上方横幅列出了已交付的内容。 +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)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 RFC 实现了这个 seam;上方横幅列出了已交付的内容。 -决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。我们预见的传输方式: +决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP(Agent Client Protocol))。我们预见的传输方式: -- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于已有的 agent 工厂几乎零成本); +- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); -- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外「启动子 agent、发送提示词、流式更新、取消」形态。 +- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。 ## 曾考虑的替代方案 -### 为什么不用 bash seam 的形态 +### 为何不采用 bash seam 的形状 -bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**:每个实现以唯一名称注册,调用方按名称选取。这与 **LLM 适配器注册表**(`LlmService.registerAdapter`)同构,而非单服务的 bash 执行器。seam 仍然是三包结构(接口 / 实现 / 消费方);唯一不同的轴是「单实现 vs. 多实现」。 +bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。 ## 决策 -### 三包 seam +### 由三个包构成的 seam -新增包组 `packages/subagent/`: +新建包(package)组 `packages/subagent/`: | 包 | 角色 | |---|---| -| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService`(`ctx.subagents`)、`SubagentProvider`、`SubagentRun`、请求/结果/能力词汇表、`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 | +| `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent | | `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 | -| `@deepseek-ai/dsh-subagent-mock` | 支撑:脚本化的提供方,用于通过真实加载路径测试 seam | +| `@deepseek-ai/dsh-subagent-mock` | 辅助:用于通过真实加载路径测试 seam 的脚本化提供方 | | `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 | -### 基本原语:异步 `start → SubagentRun` +### 原语:异步 `start → SubagentRun` -提供方暴露 `start(request) → Promise<SubagentRun>`。完成后发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()` 取消剩余工作并等待静默。启动失败时清理部分资源,不发出生命周期事件。`start` 是传输无关的;`spawn` 仅命名全新进程内后端。 +提供方暴露 `start(request) → Promise<SubagentRun>`。完成时发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()`(资源释放)取消剩余工作并等待静止。启动失败时清理部分资源,不发出生命周期事件。`start` 与传输方式无关;`spawn` 仅指代全新的进程内后端。 ### 两类可选能力,两种发现方式 -- **启动时特性**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态 `provider.capabilities` 描述符上。服务在委派之前检查每一项请求的特性,若提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不「接受后静默忽略」。它们必须在 run 存在之前被检查,这就是为什么不能做成运行时方法。 -- **运行时特性**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续交互)是 `SubagentRun` 上的**可选方法**。方法的存在即是能力,TypeScript 窄化即是发现机制:消费方不经窄化就无法调用不存在的方法,因此不存在静默降级路径,也不需要一个单独的 flags 对象来保持同步。 +- **启动时特性**(`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` 以仅包含已完成父轮次的平衡前缀为种子。进行中的轮次被排除,因为其 subagent 调用尚无结果,无法构成有效的回放历史。 +全新子 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 保持传输无关。 +每个 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 中没有 provider/type 参数。 +`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——本版 schema 中没有 provider/type 参数。 ## 测试 -seam 通过真实的 Cordis Loader/export 路径测试,这能捕获 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[按会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e。 +seam 通过真实的 Cordis Loader/export 路径测试,这能捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状错误。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。 ## 后果 -- **递归。** 若无限制,进程内子 agent 能看到委派工具并递归。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有它们的确切语义和安全限制。 -- **阻塞父轮次。** 同步收集在子 agent 的整个持续期间保持父 agent 的 `runStep` 打开。这对第一版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该重新设计将统一 subagent 与 bash 的长时运行工具处理**(一个 sub-agent 和一个长时间运行的 `bash` 后台任务面临相同的「模型启动了一个慢操作,之后如何收集结果」问题,应共享一套机制而非各自发明)。 -- **实时进度。** 本版仅暴露生命周期事件和最终结果;逐分片的子→父更新流推迟到后台重新设计。 -- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;第一版不声明这两项能力,子 agent 在自己的进程中自给自足。 +- **递归。** 如果不设限制,进程内子 agent 能看到委派工具并递归调用。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`;ACP 声明这两项能力为关闭状态,并拒绝此类请求。[subagent 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责定义它们的确切语义和安全边界。 +- **阻塞父轮次。** 同步收集在子 agent 的整个持续时间内保持父 agent 的 `runStep` 打开。这对首版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该设计将统一 subagent 和 bash 的长时间运行工具处理**(一个 subagent 和一个长时间运行的 `bash` 后台任务面临相同的问题——「模型启动了一个慢操作,之后如何收集结果」——应共享一套机制,而非各自发明)。 +- **实时进度。** 本版仅暴露生命周期事件与最终结果;逐分片的子→父更新流推迟到后台重新设计时一并处理。 +- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent(共享工作区模式)是后续工作;首版不声明这两项能力,子 agent 在自己的进程中自行服务。 diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index a34398b188..2939af3939 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -3,4 +3,4 @@ # 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: 7eb03ddf68f54c29524944e7b8bc801eb1724fe6 -2026-06-22-acp-subagent-backend.zh.md: 6f7b95318a2c714fea43a584ba49da00a7c12040 +2026-06-22-acp-subagent-backend.zh.md: 249f5a5ebf18d42f3d83d2159f6c2bcb52a245c3 diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 6f7b95318a..249f5a5ebf 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -1,57 +1,57 @@ # RFC:ACP subagent 后端(进程外委派) -Status: implemented - [English](2026-06-22-acp-subagent-backend.md) | 中文 +Status: implemented + ## 问题 -subagent seam(见 [seam RFC](2026-06-21-subagent-capability-seam.md))的设计使得多个后端可以按名称共存于 `ctx.subagents` 上。进程内后端(`-spawn`/`-fork`)将子 agent 作为同一个 Cordis 上下文上的第二个 `Agent` 运行——开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义正是还要支持通过协议到达的进程外子 agent,以证明这层抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。 +subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在**同一个** Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的**进程外**子 agent,以证明该抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent 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 与自身进程对话。 +`@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」的形态一致。 +每次 `start` 都 spawn 一个新的子进程,运行恰好一个 ACP 会话(`initialize` → `newSession` → `prompt`),`dispose` 杀死子进程并等待其退出。这是最简单的生命周期,与进程内「每次运行一个子 agent」的形态一致。 -### 最小客户端桩 +### 最小化客户端桩 -客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费——后端累积 `agent_message_chunk` 文本作为结果输出,在本次实现中忽略其余内容(思考、工具调用卡片),仅呈现子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝每个提示,`allow` 通过第一个 allow 形态的选项批准)——本次实现不将任何提示呈现给人类。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍是未来工作,如 seam RFC 所述。 +客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam RFC 所述。 ### 无启动时能力 -提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无法访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),且本次实现未实现 `outputSchema`。服务在 `start` 运行之前就会拒绝需要上述任何能力的请求。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`。 +提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无权访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),本阶段也未实现 `outputSchema`。如果请求需要其中任何一项,服务在 `start` 运行前即拒绝。后端仅注入 `subagents`(而非 `ctx.agents`),并忽略 `request.parent`。 ### 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。 +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` 而非以未处理错误崩溃父进程。 +子 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 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载,以及命名空间导出。 -- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`、写入 `proof.txt`,父进程验证该文件。 -- **快照缺口:** 每个 ACP 子 agent 是独立进程、拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock-server 覆盖已有;`TODO(acp-subagent-replay)` 跟踪父 agent 对回放中子 agent 的回放支持。 +- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。 +- **需要密钥的 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 层同时迁移客户端和服务端连接类,但不会改善本后端,因此升级作为独立变更保留。 +后端只需要 `ClientSideConnection`、`ndJsonStream`、`PROTOCOL_VERSION` 和客户端协议类型,0.25.1 全部支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,却不会改善本后端,因此升级作为独立变更保留。 ### 为何不使用持久子进程? -持久进程池(跨运行复用热子进程)是一项性能优化,推迟到未来工作——它引入会话生命周期和崩溃恢复的复杂性,本次实现不需要;每次 `start` spawn 新子进程与进程内「每次运行一个子 agent」的形态一致。 +持久进程池(跨运行复用热子进程)是一项性能优化,推迟到后续工作。它增加了会话生命周期和崩溃恢复的复杂度,本阶段不需要;每次 `start` spawn 全新子进程与进程内「每次运行一个子 agent」的形态一致。 ## 后果 -每次运行都要付出一个新子进程的开销(spawn + `initialize` + `newSession`)。父 agent 仅呈现子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示永远不会到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥须通过 `config.env` 显式提供。 +每次运行都要付出一个全新子进程的代价(spawn + `initialize` + `newSession`)。父进程仅暴露子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥需通过 `config.env` 显式提供。 -## 未来提供方 +## 后续提供方 同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam RFC 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml index c46a38e46f..30f57427e2 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml @@ -3,4 +3,4 @@ # 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: 06673233038d10214f8de3d1f29766d43b575442 -2026-06-25-ask-user-question.zh.md: 01d1284dba3622984d5403f94e3edd0ba02583b6 +2026-06-25-ask-user-question.zh.md: a036220fd54e3f634ab4be80a45964b076d3fd2d diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md index 01d1284dba..a036220fd5 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md @@ -1,51 +1,51 @@ # RFC:ask-user 提问能力 -Status: implemented - [English](2026-06-25-ask-user-question.md) | 中文 +Status: implemented + ## 问题 -agent(智能体)有时仅凭模型推理(inference)无法安全地继续:它需要人类选择路径、确认有风险或默认的操作,或提供缺失的信息。在此变更之前,获取答案的唯一方式是模型在 assistant 文本中提问然后停止,这会打断正常的工具调用循环:agent 没有结构化的暂停手段,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的方式。 +agent(智能体)有时仅凭模型推理(inference)无法安全地继续执行:它需要人类选择路径、确认有风险的或默认的操作,或者提供缺失的信息。在此变更之前,获取答案的唯一方式是模型在 assistant 文本中提问然后停止,这打断了正常的工具调用循环:agent 没有结构化的暂停方式,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的途径。 -这是一个面向用户的能力,但它也跨越了包(package)边界。模型侧的工具需要一套提供方无关的请求词汇;每个 UI 表面需要决定如何展示和收集答案;agent loop(智能体循环)应保持不变,因为工具调用本身已具备正确的异步形态。 +这是一个面向用户的能力,但它也跨越了包(package)边界。面向模型的工具需要一套提供方无关的请求词汇;每个 UI 界面需要决定如何展示和收集答案;agent loop(智能体循环)应保持不变,因为工具调用本身已具备正确的异步形状。 ## 决策 -引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与模型侧消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之:向人类提问是一种由 UI 支撑的产品能力,不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品表面提供收集答案的具体 provider。工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。 +引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与面向模型的消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之的:向人类提问是一种由 UI 支撑的产品功能,不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品界面提供收集答案的具体 provider。该工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。 -模型侧的请求词汇有意与产品研究 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`。`id` 按问题提供并在结果中回传,使批量请求可以路由而不依赖问题文本。`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。 +面向模型的请求词汇有意与产品调研 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`。`id` 按问题提供并在结果中回传,使批量请求无需依赖问题文本即可路由。`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。 -provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形态。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖所有已选选项,`selected` 为空。 +Provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形状。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖任何已选择的选项,`selected` 为空。 -`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER`、`ASK_ABORTED`、ACP 取消或会话路由缺失等失败会以可机器路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能区分「用户取消」与通用抛出异常。 +`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER`、`ASK_ABORTED`、ACP(Agent Client Protocol)取消或会话路由缺失等失败会以机器可路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能够区分「用户取消」与一般的抛出异常。 ## UI 映射 -`dsh-stdio-demo` 的包内 readline 模块逐题渲染每个问题,在下一行展示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序逐题询问,合并为一个答案对象返回。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个 prompt 占用 stdin。 +`dsh-stdio-demo` 的包内 readline 模块渲染每个问题,在下一行显示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序询问,作为一个答案对象整体解析。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个提示占用 stdin。 -`dsh-acp` 为 ACP(Agent Client Protocol)会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由到对应会话,并为每个问题调用 ACP `unstable_createElicitation`(携带会话作用域的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation 的情况都会变为结构化的 `UserInteractionError`。 +`dsh-acp` 为 ACP 会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由出去,并为每个问题调用 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 prompt 到达,而非作为需要答案的那次操作的结果。 +**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化选项元数据,UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user prompt 到达,而非作为需要答案的那次操作的结果。 -**核心包拥有 ask-user 相关包。** 最初实现将 seam 和模型侧工具分别放在 `packages/core` 和 `packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互能力。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction` 和 `dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包结构与产品边界一致:应用和 bridge 提供人类答案的 provider,stdio 应用选择性加载模型侧工具。 +**核心拥有的 ask-user 包。** 最初实现将 seam 和面向模型的工具分别放在 `packages/core` 和 `packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互功能。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction` 和 `dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包的划分与产品边界一致:应用和 bridge 提供人类答案的 provider,stdio 应用选择性加载面向模型的工具。 **ACP `session/request_permission`。** 权限请求是围绕工具执行的授权;`ask_user_question` 是带可选自由格式答案的信息收集。将权限用于通用提问会混淆两个不同的产品概念,并使未来的权限门禁更难推理。 -**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。新增一个循环特例会重复这一异步形态,并迫使每个循环实现都了解一个 UI 关注点。 +**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。添加新的循环特殊分支会重复这一异步形状,并迫使每个循环实现都了解一个 UI 关注点。 ## 后果 -ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应留在 `dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。 +ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应限制在 `dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。 -该特性赋予模型一个强大的暂停原语,因此提示词引导很重要。工具描述告诉模型提问要简洁、尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。 +该功能赋予模型一个强大的暂停原语,因此 prompt 引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。 -`dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和模型侧工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶子节点必须在其客户端能够完成 elicitation 请求后才有意加载模型侧工具。 +`dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和面向模型的工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。 ## 测试 -单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc` 的验证)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流程。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。 +单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc`)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。 diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index d2c28a3946..677df03ea6 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -3,4 +3,4 @@ # 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: 69f81cf6fd93df63ce53bb82c97dbac16dbbd486 -2026-06-29-todo-write-tool.zh.md: a687f5ab4bc5b9fcd5583ca4aac2857ab4c3f513 +2026-06-29-todo-write-tool.zh.md: eb3c6fb8a9ddc7d26e4a620761c96a8d35ddf469 diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md index a687f5ab4b..eb3c6fb8a9 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -1,4 +1,4 @@ -# RFC:`todo_write` 工具——将模型任务列表建模为事件溯源的会话状态 +# RFC:`todo_write` 工具——将模型任务列表作为事件溯源的会话状态 Status: implemented @@ -6,59 +6,59 @@ Status: implemented ## 问题 -harness 为模型提供了 bash 和 subagent 工具,但没有任何方式记录结构化的任务列表。todo 列表服务于两个同等重要的目的:引导模型规划多步骤工作并保持当前任务明确(最多一个 in_progress,有未完成工作时恰好一个),以及为人类提供实时进度清单。ACP(Agent Client Protocol)协议有原生的 `plan` sessionUpdate,编辑器(Zed)已经在渲染它,但 bridge 从未发出过。调研的每个参考编码 agent(智能体)实现(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;而 harness 什么都没有。 +harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为人类提供实时进度清单。ACP(Agent 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。 +新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。stdio UI 和 ACP bridge 均从现有的 `session/event` 渲染;ACP bridge 将列表映射为 `plan` sessionUpdate。 -### 全量替换,三态 status +### 整列表替换,三态 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 映射,无损失转换。 +模型每次调用发送**完整**列表;新列表替换旧列表(回放时 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` 服务需要重新发明所有这些。 +列表作为 `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* 状态,伴随对话传播但不属于对话的一部分。(开发模式的不变式仍要求它位于一个打开的轮次内,事实也确实如此:它在工具调用的 mid-step 阶段追加。) +`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入 surface 链表,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。) -### priority 仅在 ACP 边界合成 +### Priority 仅在 ACP 边界合成 -ACP 的 `PlanEntry` 要求 `content` + `priority` + `status`,但 `TodoItem` 没有 priority——模型从不推理它。与其在 schema 中增加一个模型每次都必须提供的字段,不如让 bridge 在构建 `plan` 时为每个条目合成一个常量 `priority: 'medium'`。priority 是 ACP 协议格式(wire format)的要求,不是 harness 的概念,因此它恰好存在于需要它的边界处。 +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 舍弃的字段:`activeForm`、id、priority -claude-code V1 的 item 是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但那只是为了支持 agent *集群*(磁盘持久化、锁保护、逐项变更)。本工具将 item 保持在最小集:`{ content, status }`。没有 `activeForm`(现在进行时标签)——UI 直接展示 `content`;没有 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 和显式作用域选择,因此留作未来独立设计。 +每个列表属于调用它的 agent 会话,非 agent 调用被拒绝。没有共享作用域、resolver 或 delta 协议。跨 agent 列表需要逐项日志 delta 和显式作用域选择,因此留作未来独立设计。 ### 校验:低成本的中间路线 -schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content` 以及多于一个 `in_progress` 任务。claude-code 将 single-in-progress 留给 prompt;oh-my-pi 在代码中强制。我们取中间路线:强制那些使计划*连贯*的低成本不变式(无空白任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述留给模型。被拒绝的写入返回 `isError` 结果,模型可自行修正。 +schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给 prompt 约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。 -## 为什么没有 cordis-catalog 条目 / 没有 `@mode` +## 为何没有 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` 成员要求此标签)——加上它也没有意义。 +`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` 更新。 -- **带 key 的 e2e + 快照**——一个真实 prompt 诱导 `todo_write`;快照 golden 新增 `plan` 通知和日志事件。 +四个层级,预先设计: +- **单元测试**——会话事件(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 + 快照**——真实 prompt 诱导一次 `todo_write`;快照 golden 获得 `plan` 通知和日志事件。 ## 曾考虑的替代方案 - **内存中的 `ctx.todos` 服务**——需要重新发明日志免费提供的持久性、回放和 `session/load` 重建。 -- **逐项 delta 协议**——仅在共享多所有者列表时需要,不在本次范围内;全量替换更简单且与参考实现一致。 -- **工具放在 `core/`**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它与其他工具族一样放在自己的 `packages/todo/` 分组中。 +- **逐项 delta 协议**——仅在共享多所有者列表时需要,超出当前范围;整列表替换更简单,且与参考实现一致。 +- **工具放在 `core/` 中**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它像其他工具族一样位于自己的 `packages/todo/` 分组中。 ## 后果 -todo 列表是持久的、可回放的会话状态:一条持久化的 `todo/write` 在 `session/load` 时重新向编辑器发出 `plan` 更新,日志(而非插件内存)是唯一真源。全量替换意味着每次更新一次工具调用、last-write-wins;没有需要协调的 delta 协议。事件不进入 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。 +todo 列表是持久、可回放的会话状态:持久化的 `todo/write` 在 `session/load` 时重新发出编辑器的 `plan` 更新,日志(而非插件内存)是唯一真源。整列表替换意味着每次更新一次工具调用,last-write-wins;没有需要协调的 delta 协议。事件不进入 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 5402e85e81..03e276e446 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-hook-bridges.md: 17ff57307c34121c845592efa93c723e66c98886 -2026-06-30-hook-bridges.zh.md: 2a94d5cca2f490e4aac493fe357a825ad3b4d271 +2026-06-30-hook-bridges.zh.md: a4b8c12593cdac35deb882ba15a58876650c1653 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md index 2a94d5cca2..a4b8c12593 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -1,4 +1,4 @@ -# RFC:dsh-hooks-claude + dsh-hooks-codex——Claude Code / Codex 钩子桥接插件 +# RFC:dsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件 Status: implemented @@ -6,65 +6,65 @@ Status: implemented ## 问题 -harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](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 Code(CC)和 Codex 钩子配置到来——一个 `hooks.json`(或设置文件中的 `hooks` 键)里满是 shell 命令钩子——并且希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,基于共享的协议格式(wire format)库(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md))构建。 +harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](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 Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md))。 -贯穿整个设计的定位是:**桥接是兼容性适配器,不是高级工具。**桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能更强力地完成——有类型化返回值、完整的 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射到 seam 的 Decision。各 package 的 README 记录了当前相对官方协议的不支持事件与部分字段清单。 +贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各 package 的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 ## 决策 -`packages/hooks/` 分组下两个独立插件,各自为函数/命名空间插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: +`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../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`/`cwd`/`hook_event_name`,加上每事件特有字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则匹配模式。CC 钩子的 stdin 带有**尾随换行**。 -- **`dsh-hooks-codex`**——Codex 当前钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形状的 snake_case payload(带 `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾随换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接的精简 `tool_input: { command }` 形状中携带真实的 `tool_name`。 +- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。CC 钩子的 stdin 带有**尾部换行**。 +- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 -### 结果 → Decision 映射 +### Outcome → Decision 映射 每个桥接将共享库返回的中性 `MergedHookOutcome` 映射到 seam 的类型化 Decision: | Seam | CC | Codex | |---|---|---| -| `agent/session-start`(emit) | additionalContext → `agent.inject()` | plain-stdout 输出 → additionalContext → `agent.inject()` | +| `agent/session-start`(emit) | additionalContext → `agent.inject()` | plain-stdout output → 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 → 注入进程内活跃子 agent;远程子 agent 没有本地注入目标 | 本桥接不支持 | +| `agent/turn-continuation` | 阻塞的 Stop → `continue`(reason = next-step 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` 关闭。 +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` 为插件而非用户。 +`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()` 和 `HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `context/message.source` 为插件而非用户。 ### 添加上下文不是否决——先 delegate,再 fold -仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext` 折入下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。 +仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext` 折叠进下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。 ### CLAUDE_PROJECT_DIR 默认为会话工作区 -Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 `$CLAUDE_PROJECT_DIR` 来构造项目相对路径。显式的 `config.projectDir` 优先;当它被省略时(默认的 ACP 接线只配置 `configPath`),桥接将该环境变量按每次运行默认为 agent 的会话工作区——即钩子已经运行其中的 `session.header.cwd`——而不是留空。因此一个标准的项目相对钩子在默认配置下即可工作。 +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)。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)` 下。 +钩子在 agent 的会话工作区中运行,因此相对路径指向用户的项目。`configPath` 相对于进程启动时的 cwd 解析一次,适用于所有会话。按会话的项目本地发现仍推迟在 `TODO(per-session-hook-config)` 下。 ## 推迟的兼容性缺口 -- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不生效——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为预执行参数被 `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`);共享 merge 将其折入 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一起推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(decision/上下文)。 -- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型均未重新实现(`TODO(per-session-hook-config)`)。 -- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动之外,因此其上下文在就绪时注入,但可能错过第一个请求或短命子 agent。保证首请求送达需要一个 awaited 的启动 seam。 +- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite RFC](../../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* 等待钩子 *N−1*)且逐钩子超时不重叠——对真实配置使用的钩子数量而言可接受;如果某天配置扇出到足以影响挂钟时间,再重新审视。 +**每点钩子并发执行。** 参考引擎对一个点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)以及每钩子超时不重叠——对真实配置中的钩子数量可以接受;如果某配置的扇出大到影响总耗时,再重新评估。 ## 后果 -匹配语义、退出码处理与合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支加上通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形状。原生插件绕过协议格式,直接返回类型化决策。 +匹配语义、退出码处理和合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支以及通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形态。原生插件绕过协议格式,直接返回类型化决策。 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 22f7710469..5979186c01 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -3,4 +3,4 @@ # 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: 924c320f7ef9fdb55b20ff06f492addbf42d1720 -2026-06-30-hook-protocol-lib.zh.md: 2f1cf0f1e4c99eaf1172642475e3b9bc8c8aed39 +2026-06-30-hook-protocol-lib.zh.md: 81315cbe8767e9a3cc07cdef92359734e4e20f31 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 2f1cf0f1e4..81315cbe87 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -1,4 +1,4 @@ -# RFC:dsh-hook-protocol——Claude Code / Codex 钩子协议格式的共享核心库 +# RFC:dsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库 [English](2026-06-30-hook-protocol-lib.md) | 中文 @@ -6,27 +6,27 @@ Status: implemented ## 问题 -钩子子系统提供两个桥接插件:一个运行用户已有的 Claude Code(CC)钩子,一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。**它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型——Codex 的源码甚至以 Claude 的引擎命名自己的引擎,并在注释中标注了「有意偏离」之处。因此两个桥接插件如果各自实现,将重复协议的大部分内容。 +hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Code(CC)钩子,另一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型。Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了"有意偏离"之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑。 -本 RFC 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的、真正相同的原语。共享与方言各自持有的部分之间的切分,是本设计的重心所在。 +本 RFC 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言专属之间的分界是本设计的重心。 ## 决策 -在 `packages/hooks/` 下新建一个组,`hook-protocol` 作为纯库存在。它拥有四个原语族以及 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 +在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher**——`matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的交替),其他视为正则;`codex` 始终为无锚定正则。缺失/`''`/`'*'` 时匹配全部;无效正则匹配空集(绝不向循环抛出异常)。 -- **Execution**——`runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已经提供了经过清理但可覆盖的 env、进程组 kill 和超时——正是协议所需的能力,而 `dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),尊重钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛出异常(执行器的 rejection 变为 non-blocking-error 的 `HookOutput`)。 -- **Decode**——`parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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,因此没有什么可抑制的;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 -- **Merge**——`mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block 原因以 `\n\n` 拼接,context/system-messages 按序累积。 -- **`hook/*` 会话事件**——`hook/invoked` / `hook/result`,通过 declaration-merge 加入 `SessionEventMap`(仅记录日志,类似 `compact/*`——不是 `SurfaceEventType`),附带 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对和轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义——决策字符串(钩子解析出的 decision,否则在 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从此处的 `HookOutput` 导出,而非在各桥接插件中分别实现。 +- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。 +- **Execution** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 +- **Decode** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 +- **Merge** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block reason 以 `\n\n` 拼接,context/system-messages 按序累积。 +- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,declaration-merge 进 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与 turn 包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 -**方言各自持有(桥接插件):**构建每个事件的 stdin payload(CC 的 base + per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射到 harness 的 seam 特定类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 +**方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 ## 曾考虑的替代方案 -**一个参数化引擎。** 否决,因为 payload 构建和决策映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享;各桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。 +**单一参数化引擎。** 否决,因为 payload 构建与 decision 映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享;每个桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。 ## 后果 -每个桥接插件解析配置、构建方言 payload、调用共享的 runner 和 merge 逻辑、映射决策、追加 `hook/*` 事件。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已被解析,但在 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地之前仅记录日志并发出警告。 +每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与 merge 逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml index b40109802a..2a1efb261c 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-interception-seams.md: fb8efe1e1c2057db13b440881f110ca7f579a81e -2026-06-30-interception-seams.zh.md: b22b3d61bd14b6708e5a063f02537e981fead0fc +2026-06-30-interception-seams.zh.md: 668b96dba282ecdcbe85cc0b1dc56c2de293b3b3 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md index b22b3d61bd..668b96dba2 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md @@ -6,55 +6,55 @@ Status: implemented ## 问题 -harness 需要一套钩子子系统:用户在生命周期节点扩展或拦截 agent(智能体),方式类似 Claude Code(CC)和 Codex。驱动本设计的关键重构是:**"原生钩子"不是一个 package**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是把外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件都能直接做——而且更强大(没有序列化边界、完整的 `ctx`、类型化的返回值)。 +harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**"原生钩子"不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。 -这个表面需要为以下各阶段提供不同的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及附带面向模型原因的继续。如果把这些阶段混为一谈,插件就会获得不需要的修改通道,终态也会依赖监听器顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则和类型化 Decision 惯用法;本 RFC 将它们应用到生命周期 seam 上。 +该表面需要为以下场景提供各自独立的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则与类型化 Decision 惯用法;本 RFC 将其应用于生命周期 seam。 ## 决策 -规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回归一化结果;通知接收不可变快照,不能影响结果。覆盖范围包括本次纳入的钩子点(`session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`),同时将非钩子的执行策略留给独立组合。 +规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 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, next) → PromptDecision`——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。 +- `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,**不能**阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` ——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。 -**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化的孪生。 +**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。 ### 工具流水线为每个阶段赋予一种权限 -每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → `tools/result`。注册表快照调用方输入、物化并冻结参数、分配不透明 token。嵌套调用只携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「运行了什么」达成一致。 +每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → `tools/result`。注册表快照调用方输入、实体化并冻结参数、分配一个不透明 token。嵌套调用仅携带父 token。身份始终不可变;只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「执行了什么」达成一致。 -- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 和核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均归一化为拒绝。每种结果仍会到达后策略和最终观测者。 -- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的作用域感知策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。 -- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已归一化的抛出或未知工具结果;返回自己的有效结果可短路调度。 -- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的就地修改不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。 -- **`tools/result`** 是每次变换、无损 JSON 物化和外层错误边界之后的同步受限通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者失败按监听器隔离,不能改变或拒绝 `ToolRegistry.execute()` 返回的结果。 +- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 与核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每种结果仍会到达后策略与最终观测者。 +- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的、作用域感知的策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。 +- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已规范化的抛出或未知工具结果;返回自己的有效结果则短路调度。 +- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的原地 mutation 不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。 +- **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步封闭通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。 -核心调度和工具体位于归一化边界内,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状失败都解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查抛出异常的工具,最终观测者看到的恰好是调用方收到的、会话日志可持久化的内容。 +核心调度与工具体位于规范化边界内部,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具,最终观测者看到的正是调用方收到的、会话日志可以持久化的内容。 -**`TurnEndReason.rejected`**(`dsh-session`):整个 prompt 批次被 `prompt-submit` 阻止的轮次。 +**`TurnEndReason.rejected`**(`dsh-session`):整批 prompt 均被 `prompt-submit` 阻止的轮次。 ### 三个承重的循环决策 -1. **在 prompt 策略之前开启轮次。** 被完全阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP 提供持久的终止事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留了被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。 +1. **在 prompt 策略之前开启轮次。** 全部被阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。 -2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤缓冲每次调用的上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。 +2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可以携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤的每次调用缓冲上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。 -3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使下一步骤的循环顶部 drain 将其记录为继续轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与既有的 `hasSteering` force-continue 覆盖一致)。 +3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与现有的 `hasSteering` 强制继续覆盖一致)。 ### Pre-tool 输入重写是一个独立的一致性决策 -`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。 +`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/*` 协议。压缩(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终止的单调停止由 `agent/turn-stop` 独立负责。 +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` 独立负责。 ## 曾考虑的替代方案 -- **将 pre-tool 输入重写作为本 seam 集的一部分交付**——推迟,视为过度扩展信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。 -- **将持久的 `hook/*` SessionEvent 与 seam 一起声明**——否决:原生插件使用类型化 Decision 而完全不需要钩子日志(工作示例已证明),因此持久日志属于[钩子协议库](2026-06-30-hook-protocol-lib.md),而非 seam 表面。 +- **将 pre-tool 输入重写作为本 seam 集的一部分发布**:推迟,视为越界信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../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、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存和五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) 和[工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 +规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、各 package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) 与[工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index 03da25497d..c05a025ca6 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml @@ -3,4 +3,4 @@ # 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: 4bf5c3fe43821570fd947034358e54d0a0a602f9 -2026-06-30-session-store-fork-api.zh.md: 3dd15f5beb095fecb7fdaa7d80abcf4b99c07920 +2026-06-30-session-store-fork-api.zh.md: a3ffb881a446647861fa5fbaf57dde291838a090 diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md index 3dd15f5beb..a3ffb881a4 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -1,20 +1,20 @@ # RFC:SessionStore fork API -Status: implemented - [English](2026-06-30-session-store-fork-api.md) | 中文 +Status: implemented + ## 问题 -事件溯源的会话日志已经具备 fork 所需的原语:创建一个新会话并带上种子事件前缀,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法的种子,但普通的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以复制、子会话打上什么元数据、错误如何分类。 +事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。 -语义风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且被轮次封闭。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`,可能还有未关闭的 `step/start`,以及悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子会话历史——看起来像是参与了父会话中一个未完成的轮次。现有的 [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次尚未关闭时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝。 +语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](../../implemented/feature/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 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久性工作都委托给现有的会话存储与持久化后端。 +`dsh-session` 直接在 `ctx.sessions` 上拥有常规活跃会话 fork 的能力。不设独立的 `dsh-session-fork` 包(package),也不设 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久化工作都委托给现有的 session store 和持久化后端。 -存储暴露一个操作: +store 暴露一个操作: ```ts ignore-check type SessionForkSource = Session | SessionId @@ -24,20 +24,20 @@ class SessionStore extends Service { } ``` -`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 会创建一个空的子会话。fork 专有的校验只检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 标记为源会话 id,并将 `seedLength` 设为复制的前缀长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 +`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验仅检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id,并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。 -空前缀可以 fork;任何非空边界必须是一个安全的、已存在的、位于 `turn/end` 处的序号,无论结束原因是什么。类型化的错误区分源不存在、对象陈旧、子会话 id 重复和边界无效。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 +空前缀可以被 fork;任何非空边界都必须是一个安全的、已存在的、位于 `turn/end` 的序号,无论结束原因为何。类型化的错误区分源缺失、对象陈旧、子 id 重复和边界无效等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。 ## 曾考虑的替代方案 -**独立的 `ctx.sessionFork` 服务。** 这是第一版实现,但评审表明它过度套用了能力 seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方发现并安装第二个服务,仅仅为了在会话存储原语之上执行策略。 +**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在 session store 原语之上执行一层策略而去发现并安装第二个服务。 -**两个函数:`snapshot()` 加 `fork()`。** 这保留了可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还让接口感觉比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 保持了 API 的直接性,同时仍支持对先前时间点的 fork。 +**两个函数:`snapshot()` 加 `fork()`。** 这保留了一个可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还使接口看起来比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 使 API 保持直接,同时仍支持对先前时间点的 fork。 -**静默裁剪未关闭的轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的,因为委托通常在父轮次尚未关闭时开始,子会话应只继承已完成的前缀。但对普通的用户/会话分支来说是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并静默丢弃了父轮次的尾部。 +**静默裁剪未关闭轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的——委托通常在父轮次仍然打开时开始,子会话应只继承已完成的前缀。但对常规的用户/会话分支而言是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并且静默丢弃了父轮次的尾部。 ## 后果 -公开接口保持小巧且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或两步辅助函数对。持久化继续通过现有的 `session/created` 和 `session/flush` 行为工作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化一次该种子,并在头部保留 `parentSession` / `seedLength`。 +公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 -v1 范围仍排除 ACP `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 获得专注的 `dsh-session` 单元测试加 JSONL 持久化覆盖。 +v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。 diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml index 085f5d2c5b..90b6939da2 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml @@ -3,4 +3,4 @@ # 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: b48a1fff32130345e669a3b3b905c4fda987e41e -2026-06-30-subagent-observe-enrich.zh.md: 59dc555ce4acff30f4ba0b5929b605dc5252fe38 +2026-06-30-subagent-observe-enrich.zh.md: 8ce070001e219572658fd4e94c660de1094ddee5 diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md index 59dc555ce4..8ce070001e 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md @@ -1,4 +1,4 @@ -# RFC:Subagent 生命周期充实——lastAssistantMessage(仅观测) +# RFC:Subagent 生命周期丰富化——lastAssistantMessage(仅观察) Status: implemented @@ -6,26 +6,26 @@ Status: implemented ## 问题 -钩子子系统([拦截 seam RFC](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`)——不足以让钩子桥接层在不另行访问活跃运行的情况下报告 subagent 产出了什么。 +钩子子系统([拦截 seam RFC](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 产出了什么。 -本 RFC 充实 end 载荷。它刻意限定为**仅观测**:不改变控制流,不引入 waterfall(瀑布式事件)。影响运行的 subagent-stop 决策(续行、注入改变运行的内容)属于另一项更大的重新设计,不在本 RFC 范围内。 +本 RFC 丰富 end 载荷。它刻意限定为**仅观察**:不改变控制流,不引入 waterfall(瀑布式事件)。影响 run 的 subagent-stop 决策(续行、改变 run 的注入)属于另一个更大的重设计,不在本 RFC 范围内。 ## 决策 -**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结算路径上,它是只读的类型化 `SubagentResult.output`,观测者无需持有运行即可看到子 agent 的产出。在基础设施拒绝、不存在 `SubagentResult` 的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听者是受信任的同进程协作者,遵守借用不可变载荷的契约。 +**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结束路径上,它是只读的类型化 `SubagentResult.output`,观察者无需持有 run 即可看到子 agent 产出了什么。在基础设施拒绝(不存在 `SubagentResult`)的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听方是受信任的同进程协作者,遵守借用不可变载荷的契约。 -两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观测附加到就绪的提供方运行上,发出 `subagent/start`,然后返回该运行;因此进程内监听者可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程提供方无需在本地注册表中有条目。提供方启动被拒绝时不发出任何事件。回调保持仅观测,逐监听者隔离确保一个坏订阅者不会阻塞活跃运行或饿死后续监听者。 +两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观察附加到就绪的 provider run 上,发出 `subagent/start`,然后返回该 run;进程内监听方因此可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程 provider 无需在本地注册表中有对应条目。provider 启动被拒绝时不发出任何事件。回调保持仅观察,且逐监听方隔离确保一个异常订阅者不会阻塞活跃 run 或饿死后续监听方。 ## 曾考虑的替代方案 -**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物)放在请求和两个生命周期载荷上——早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(这里没有任何代码解释它,唯一的消费方是 CC 方言桥接层)。CC 桥接层改为向 Claude Code 自身的 SubagentStart/Stop `agent_type` 匹配器喂入其默认值 `"general-purpose"`,因此本 RFC 只交付一项充实:`lastAssistantMessage`。 +**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物),放在请求与两个生命周期载荷上。早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(此处没有任何逻辑解释它,唯一消费方是 CC 方言桥接层)。CC 桥接层改为直接为其 SubagentStart/Stop 的 `agent_type` matcher 填入 Claude Code 自身的默认值 `"general-purpose"`,因此本 RFC 只交付一项丰富化:`lastAssistantMessage`。 -**控制流式 `subagent/end`**——推迟;见下文。 +**控制流式 `subagent/end`**:推迟;见下文。 -## 为何仅观测,以及推迟了什么 +## 为何仅观察,以及推迟了什么 -控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听者、在进程内提供方中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重新设计(同一项重新设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观测充实;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在该重新设计发生时将落地的位置。 +控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听方、在进程内 provider 中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重设计(同一个重设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观察丰富化;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在重设计发生时的落点。 ## 后果 -钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器——无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md)(事件行文部分)和两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件的触发方式与之前完全相同,end 载荷多了一个(可选的)字段——因此不需要快照或 e2e 测试变更。 +钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器,无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../core-data-structures/subagent.md)(事件行文部分)与两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件触发方式与之前完全一致,end 载荷上多了一个可选字段——因此无需更新快照或 e2e 测试。 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index 04a6289216..25c723bde1 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-dynamic-workflows.md: 67ceebf7017f197bd800fd339b575390b3b936c1 -2026-07-05-dynamic-workflows.zh.md: 0ea8e88bfa750a9bb253c7dd3061766fe15d3630 +2026-07-05-dynamic-workflows.zh.md: 54ba0d903de228e14a53e6f64ead0f5156e61289 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 0ea8e88bfa..54ba0d903d 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -1,80 +1,80 @@ # RFC:动态工作流——脚本驱动的多 agent 编排 seam -Status: implemented - [English](2026-07-05-dynamic-workflows.md) | 中文 +Status: implemented + ## 问题 -harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立片段的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划没有持久存放处,每一步的协调都要消耗一次模型往返。Claude Code 以[动态工作流](https://code.claude.com/docs/en/workflows)的形式提供这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。 +harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立部分的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划无处持久存储,每一步的协调都要消耗一次模型往返。Claude Code 以 [dynamic workflows](https://code.claude.com/docs/en/workflows) 的形式提供了这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。 ## 决策 -在 `packages/workflow/` 下以 bash seam 的形态(接口/实现/消费方)提供一组工作流能力,加上 subagent seam 上它所需的结构化输出基础。 +在 `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`。pipeline 各阶段接收 `(prev, item, index)`,阶段间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过 journaling 延后处理,因此兼容的脚本正文在将 meta 头移入参数后,可以使用时钟和随机数。 +一次工作流调用包含 JSON `meta`(`name`、`description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被执行。正文接收 `agent(prompt, options)`、`parallel(thunks)`、`pipeline(items, ...stages)`、`phase(title)`、`log(message)` 和 `args`。pipeline 各阶段接收 `(prev, item, index)`,阶段之间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过日志化延迟处理,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。 -与 Claude Code 的一处刻意**偏离**:钩子误用——未知或延后的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——抛出 `fatal: true` 的 `WorkflowError`,组合器对 fatal 错误**重新抛出**而非将 item 置为 null。如果不这样做,一个拼错的选项会溶解为与子 agent 失败无法区分的 `null`——正是本仓库禁止的「接受后静默忽略」失败模式。一处**新增**:工具的 `args` 参数是 JSON 对象(裸列表会被包装为一个字段),以保持协议格式(wire format)的诚实。 +与 CC 有一处刻意的严格性**差异**:钩子误用——未知或延迟的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——会抛出带 `fatal: true` 的 `WorkflowError`,组合器会**重新抛出** fatal 错误而非将 item 置为 null。如果不这样做,一个拼错的选项会悄然变成一个与子 agent 失败无法区分的 `null`——这正是本仓库禁止的「被接受后被忽略」的失败模式。另有一处新增:工具的 `args` 参数是一个 JSON **对象**(裸列表被包装为一个字段),使协议格式(wire format)保持诚实。 ### seam(dsh-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](../../../core-data-structures/workflow.md)。 +`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](../../../core-data-structures/workflow.md)。 ### 引擎(dsh-workflow-workerthread):每次运行一个 worker 线程 -**信任前提**:工作流脚本与模型的 bash 访问享有相同信任级别。引擎约束有 bug 的脚本,保证 result 必定 settle、值 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 之后放置一个独立进程或 isolated-vm 引擎。 +**信任前提**:工作流脚本与模型的 bash 访问具有相同的信任级别。引擎容纳有缺陷的脚本,并保证结果已 settled、值为 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。 -**为何选择 `node:worker_threads`**:每次运行获得一个非池化 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。 +**为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。 -宿主在发布前校验元数据并解析正文。私有枚举键的 payload map 定义协议格式;待启动记录、已发布的子记录、单一取消信号、worker 死亡回收、result 优先级和 dispose 静默在协议两侧维持 subagent run 契约。[agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 拥有这些竞态算法。 +宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 静默,在此协议上保持 subagent run 契约。这些竞态算法归 [agent-scope runtime-design RFC](../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 隔离之外运行。 +**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、超时和 grace 限制均为经校验的配置。 +**值边界**:`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` 的同步形态:启动、等待、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略作为工具自身的 `tool:<toolName>` prompt 段随工具一起交付(显式请求才使用的指导——工具指导存在于工具插件中,从不放在部署 persona 里);harness 没有 ultracode 风格的 effort 门控。 +一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述**即**面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` prompt 段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。 ### 基础:subagent seam 上的结构化输出 -`SubagentStartRequest.outputSchema` 由 `dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同 schema 而不共享可变策略,dispose 子 agent 时整个附件被移除。 +`SubagentStartRequest.outputSchema` 由 `dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略,dispose 子 agent 时移除整个附件。 -输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用的外层 `run_code` 结果),在捕获进入 pending 状态后拒绝后续副作用,并在提交后不再请求模型步骤即停止子 agent。校验失败仍为可重试的工具错误;干净完成但没有已提交捕获的情况 settle 为错误。 +输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误;没有已提交捕获的正常完成以错误结算。 -`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。[agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 拥有组装、提交、守卫和终止停止的正确性算法。 +`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归 [agent-scope runtime-design RFC](../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`,带 key 的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。 +worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。built-bin 冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带密钥的 e2e 驱动真实子 agent,面向模型的工作流行为通过其所属示例进行快照覆盖。 -## 延后(本轮明确的非目标) +## 延迟(本轮明确的非目标) - **后台收集**(启动工具 → run id → 完成通知 → 收集),与 bash/subagent 后台统一一起设计。 -- **Journaling + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会将 Claude Code 的确定性禁令作为脚本契约收紧重新引入(脚本今天可以读取时钟)。 -- **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到 run 目录**(tool-call 事件已经持久记录了脚本)。 -- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延后项的消息大声拒绝)。 -- **整体运行的挂钟超时**:取消总能释放调用方(result 在 grace 内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。 -- **超越 worker 线程的引擎加固**:在同一 seam 之后放置 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。 -- **ACP 进度 UI**:基于 `workflow/*` 事件(`/workflows` 风格的视图);事件已为此存在。 -- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍为能力门控 `false`)。 +- **日志化 + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会以脚本契约收紧的形式重新引入 CC 的确定性禁令(脚本目前可以读取时钟)。 +- **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到运行目录**(tool-call 事件已经持久记录了脚本)。 +- **嵌套 `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 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。 -- **后台执行作为默认**(Claude Code 的形态):延后。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash/subagent/workflow 之间统一设计一次,而非逐工具各做一套。 -- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 的关注点,而 seam 的能力标志仍不诚实地为 `false`。 -- **Meta 嵌入脚本内作为 `export const meta = {...}`**(Claude Code 的精确格式):保持脚本自包含且 Claude Code 脚本可直接使用,但获取 meta 需要在宿主上对模型编写的文本求值。即使是空的限时 vm 上下文,在宿主读取结果对象时也无法约束脚本控制的 getter。JSON 参数消除了扫描器、求值和宿主自旋漏洞;代价是 Claude Code 脚本的 meta 头必须移入参数(正文保持可直接使用)。 -- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,且无法在不丢失转换精度的情况下对其校验。 -- **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 可以在不改变本设计的前提下进一步收窄接受的子集。 +- **宿主侧的恶意值防护**(无 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/workflow 之间统一设计一次,而非逐工具设计。 +- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 关注点,而 seam 的能力标志仍不诚实地为 `false`。 +- **Meta 嵌入脚本中作为 `export const meta = {...}`**(CC 的确切格式):保持脚本自包含且 CC 脚本可直接使用,但获取 meta 需要在宿主上执行模型编写的文本。即使一个空的限时 vm 上下文也无法约束脚本控制的 getter(当宿主读取结果对象时)。JSON 参数消除了扫描器、执行和宿主自旋漏洞;代价是 CC 脚本的 meta 头必须移入参数(正文保持可直接使用)。 +- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,也无法在不丢失转换精度的情况下对其进行校验。 +- **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 启动和 message-port RPC 的开销,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。Worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run 句柄保持控制,观察者仅接收快照。 +扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和 message-port RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项快速失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。 diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml b/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml index 0db4b2ac5a..3b3aed7c50 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-skill-system.md: 6cfd1f977ae5a1e1ad646a707a4201e57d46bc38 -2026-07-05-skill-system.zh.md: d491899e03854140c93f67d11e4079a5b6525185 +2026-07-05-skill-system.zh.md: f59fd5d850a38d7324b391a116c72c4e71ef7401 diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md index d491899e03..f59fd5d850 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md @@ -1,55 +1,55 @@ # RFC:Skill 系统——面向 agent 的渐进式指令披露 -Status: implemented - [English](2026-07-05-skill-system.md) | 中文 +Status: implemented + ## 问题 -各 agent 产品已趋同于一种 skill 模式:保持请求提示词精简,仅列出可用的指令包,待模型判定任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 和 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用行为而无需在每个轮次支付全量提示词成本。 +Agent(智能体)产品已趋同于一种 skill(技能)模式:保持请求提示词精简,仅列出可用的指令包,当模型判定某任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 与 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用的行为而无需在每个轮次支付全量提示词开销。 -DeepSeek Harness 使用同一原语,让项目级的评审指导、插件编写指导和工具使用指导存放在工作区或用户的 agent 配置旁,而非硬编码进 agent loop(智能体循环)。 +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 与 ACP 应用获得相同行为,同时嵌入式或远程提供方可在不改动注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的负责方。 +`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 stdio 与 ACP(Agent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的所有者。 -提供方插件在 `apply()` 期间同步注册。提供方成员关系是直接由 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射,而非监听注册表变更事件。提供方目录从 awaited `list()` 调用返回排序后的候选项,远程提供方在此期间执行初始化、认证和发现,同时遵守查找的 abort signal。注册表校验每个候选项,对同名 skill 按 rank、提供方注册顺序和提供方内部顺序执行 first-wins 解析,然后按 skill 名称排序摘要以保证消费方获得确定性结果。注册表仅缓存已完成的目录快照,当提供方/运行时修订版本在发现过程中发生变化时重试,因此 unload 不会将一个陈旧、不可解析的 skill 冻结进会话前缀。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project-over-user 优先级;`runtime` 作为注册表持有的提供方名称被保留。 +提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话前缀中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。 -本地提供方按 first-wins 的 rank 顺序扫描对 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,使系统持有的目录不被当作普通用户内容。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。 +本地提供方按先到先得的排名顺序扫描 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` 包解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求所声明的现代解析器,手写窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 +每个 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`。对于未挂载 fs seam 的最小上下文,Node 文件系统仍作为回退。缺失的根目录、不可读或格式错误的 skill 文件,以及提供方 `list()` 的瞬态失败均降级为 warn-and-skip,使单个坏源不会导致每个 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。 +本地 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`。会话前缀 seam 将仅用于请求的目录按 loop 实例冻结,并记录在请求头中,在不将其加入持久化历史的前提下保持可重建性。完整 skill 正文从不包含在目录中。 +`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 保留不同的工具错误。工具结果是面向模型的披露路径。 +`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 -数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md),服务签名见生成的[服务目录](../../../cordis-catalog/services.md)。 +数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../cordis-catalog/services.md)。 ## 曾考虑的替代方案 **将完整 skill 正文注入每条系统提示词。** 否决,因为这破坏了渐进式披露,使每个请求都为可能不适用的指令付出代价。 -**仅将 skill 暴露为斜杠命令。** 否决,因为模型主动加载才是核心能力;斜杠/ACP 命令广播不改变发现机制。 +**仅以斜杠命令暴露 skill。** 否决,因为模型主动加载是核心能力;斜杠/ACP 命令广播不改变发现机制。 -**将本地文件系统扫描直接放在 `ctx.skills` 内。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 同构:注册表负责冲突解析和消费方,实现负责加载。 +**将本地文件系统扫描直接放入 `ctx.skills`。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 镜像:注册表拥有冲突解决和消费方,实现拥有加载。 **使用系统提示词段落。** 否决,因为渲染后的系统提示词是单一字符串,而目录是一条具有仅请求生命周期要求的 user-role `<system-reminder>` 消息。[`agent/session-prefix`](2026-07-07-session-prefix.md) 是选定的机制:它将目录置于派生历史之前,并将组合后的消息记录在请求头中。 -**将内置 DSH 编写 skill 物化到 `~/.dsh/skills/.system`。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。 +**在 `~/.dsh/skills/.system` 下物化内置 DSH 编写 skill。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。 -**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包已覆盖配置的根目录,同时保持重复处理和目录顺序易于推理。 +**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包覆盖了配置的根目录,同时使重复处理和目录顺序易于推理。 -**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户期望能正常工作的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 +**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 ## 后果 -agent-core 主干包含一个会话前缀贡献者、一个本地提供方和一个面向模型的工具。skill 发现对 cwd 敏感,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。 +agent-core 主干包含一个 session-prefix 贡献者、一个本地提供方和一个面向模型的工具。Skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。 -目录在固定的根目录集和运行时注册修订版本下是确定性的,但不监听磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。 +目录对于固定的根目录集合和运行时注册修订版本是确定性的,但不监视磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。 ## 延后 -fork 式 skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`),以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、不强制执行这些字段。 +Fork 的 skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。 diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml index 2e89e359db..8d7fa05dfc 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-approval-seam.md: 3ef51c31216bf9f0c5d945748ab3f901ec82147c -2026-07-06-approval-seam.zh.md: cec1692d509a7c9a0680773fbdbbb18e1c90ab8c +2026-07-06-approval-seam.zh.md: 1bf679426a434c36f5363c3b70f13a8f24534df3 diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md index cec1692d50..1bf679426a 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md @@ -1,4 +1,4 @@ -# RFC:审批 seam——通过应答者瀑布式事件实现一次性权限决策 +# RFC:审批 seam——基于 waterfall(瀑布式事件)应答者的一次性权限决策 Status: implemented @@ -6,17 +6,17 @@ Status: implemented ## 问题 -两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute` 的 `ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 RFC](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们不必各自发明结果词汇、UI 路由、取消机制和审计追踪,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。 +两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute` 的 `ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 RFC](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们无需各自发明独立的结果词汇、UI 路由、取消机制和审计轨迹,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。 -路由问题的本质是归属:审批提示必须到达拥有发起请求的 agent 的那个编辑器会话(ACP 桥在一条连接上复用 N 个会话),对无人拥有的 agent(进程内 subagent、测试)默认拒绝(fail-closed),并且不介入没有组合 UI 的部署(无头模式、CI)。 +路由问题的核心是归属:审批提示必须到达拥有发起请求的 agent(智能体)的编辑器会话(ACP(Agent Client Protocol)桥在一条连接上多路复用 N 个会话),对无人拥有的 agent(进程内 subagent、测试)失败关闭,并且不侵入没有组合 UI 的部署(headless、CI)。 ## 决策 -一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制(MECHANISM)。策略(POLICY)——谁来应答、以及某个会话是否被询问——位于其外部:应答者是 `approval/request` waterfall(瀑布式事件)监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭的结果,并从中派生各自的工具结果。刻意只用一个包,而非能力 seam 的三包拆分(见「曾考虑的替代方案」)。 +一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即**机制**。**策略**——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为**一个**包,而非能力 seam 的三包拆分(见「替代方案」)。 ### 部署如何使用它 -一条 `cordis.yml` 条目挂载该 seam。不加载它即为 fail-closed 退出方式:消费方在没有注册任何审批代码的情况下拒绝无法应答的请求。 +一条 `cordis.yml` 条目挂载该 seam。不加载它就是失败关闭的退出方式:消费方在没有注册任何审批代码的情况下拒绝无法应答的请求。 ```yaml - id: approval @@ -25,11 +25,11 @@ Status: implemented # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -仅有这条条目提供的是机制而非通道:没有组合应答者时,每次 ask 解析为 `unavailable`,发起 ask 的工具调用被拒绝——默认拒绝无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭合回路:其桥注册一个应答者,通过 `session/request_permission` 向拥有该会话的编辑器发出提示,于是钩子的 `ask` 或升级请求会以一次性 Allow/Reject 提示的形式出现在已流式输出的工具调用上。`policy: never` 是无人值守姿态——每次 ask 确定性地自动拒绝,在系统提示词中声明,无人类参与。`policy` 在插件加载时针对封闭列表做校验;其他值直接抛异常。 +仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 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` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同的原因拒绝,模型可以区分它们;每次 ask 都在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided`;授权不会在发起请求的那次调用之后持续存在。 +组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;每次 ask 在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。 -以下是在此组合下的一次 ask,逐字取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,桥向拥有该会话的编辑器发出提示,用户点击 Allow once: +以下是该组合下的一次 ask,逐字取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,桥向拥有该会话的编辑器发出提示,用户点击 Allow once: ``` tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt", @@ -45,94 +45,94 @@ 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"}` 结束:什么都不执行,模型的结果携带发起方逐字的 fail-closed 文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。无头模式下,同一请求完全跳过提示并以 `unavailable` 结算。 +`escalation-rejected` 孪生场景以 `{"outcome": "rejected"}` 结束:不执行任何操作,模型的结果携带发起方的逐字失败关闭文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。在 headless 环境下,同一请求完全跳过提示,直接结算为 `unavailable`。 ### 设计细节 #### seam:机制与策略分离 -经过校验并追加 `approval/asked` 后,`request()` 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读请求、运行应答者 waterfall、与取消竞争,并将抛出异常或无效应答归一化为 `unavailable`。随后追加匹配的 `approval/decided`,通过 `ApprovalRequestId` 配对。 +经过校验并追加 `approval/asked` 后,`request()` 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读请求,运行应答者 waterfall,与取消竞速,并将抛出异常或无效应答规范化为 `unavailable`。然后追加匹配的 `approval/decided`,以 `ApprovalRequestId` 配对。 -两个审计事件都必须在一个打开的轮次内;接受或 pre-commit 追加失败会拒绝该请求。Post-commit 观察者被会话所包含。`allowed-once` 仅授权所请求的操作,服务不保留任何授权状态。 +两个审计事件都必须在一个打开的轮次内;接受或预提交追加失败会拒绝该请求。提交后的观察者由会话容纳。`allowed-once` 仅授权所请求的操作,服务不保留任何授权状态。 -应答者是 `approval/request` waterfall 监听器。监听器为其拥有的 agent 返回结果,否则调用 `next()`。没有应答者时默认为 `unavailable`;因此卸载 UI 即默认拒绝,不会留下通道。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,仅对「决定或委托」门禁使用 `prepend`。 +应答者是 `approval/request` waterfall 监听器。监听器为它拥有的 agent 返回结果,否则调用 `next()`。没有应答者时默认为 `unavailable`;因此卸载 UI 即失败关闭,不会留下悬空通道。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,仅对「先决策或委派」门禁使用 `prepend`。 `ApprovalRequest` 携带 agent、工具名、可选的 `callId`、原因和 signal。agent 同时路由提示和审计事件。请求使用 `dsh-llm` 的 `CallId` 而不导入 `dsh-tools`,避免包循环。工具参数被省略,因为 UI 应答者附着在已渲染的调用上。 #### dsh-tools 中的 Ask 路由 -`ToolRegistry.execute()` 在拒绝路径之前将 `ask` 发送到审批 seam。只有 `allowed-once` 才继续执行;拒绝、取消和通道不可用产生三种模型可见的不同原因。注册表按调用查找可选服务,因此缺失或未加载的服务默认拒绝,不会阻塞注册表 fiber。无 agent 的执行同样默认拒绝,因为无法路由或审计。 +`ToolRegistry.execute()` 在进入拒绝路径之前,将 `ask` 发送到审批 seam。只有 `allowed-once` 才继续执行;拒绝、取消和通道不可用产生三种模型可见的不同原因。注册表按调用查找可选服务,因此服务缺失或未加载时失败关闭,不会阻塞注册表 fiber。无 agent 的执行同样失败关闭,因为无法路由或审计。 #### 每会话策略层 -seam 拥有会话策略 `'ask' | 'never'`,遵循[沙箱 RFC](2026-07-06-sandbox.md) 中的切换契约。生效的会话或配置策略在应答者之前应用:`'never'` 在 `request()` 内部拒绝,而 `'ask'` 派发请求,无人应答时降级为 `unavailable`。系统提示词仅声明确定性的 `'never'`;叙述者报告切换,每个请求仍然收到其审计对。 +seam 拥有会话策略 `'ask' | 'never'`,遵循[沙箱 RFC](2026-07-06-sandbox.md) 中的切换契约。生效的会话或配置策略在应答者之前应用:`'never'` 在 `request()` 内部直接拒绝,`'ask'` 则派发请求,无人应答时降级为 `unavailable`。提示词仅声明确定性的 `'never'`;叙述者报告切换,每个请求仍收到其审计对。 #### ACP 应答者 -ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/request_permission`,并将一次性 allow、reject 和 cancel 响应映射到 seam 词汇。未知选项永远不授权。外部 agent 和没有 `callId` 的请求通过 `next()` 委托;RPC 失败变为 `unavailable`。桥应答请求但不决定哪些调用需要审批。 +ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/request_permission`,并将一次性 allow、reject、cancel 响应映射到 seam 词汇。未知选项永远不授权。外部 agent 和没有 `callId` 的请求通过 `next()` 委派;RPC 失败变为 `unavailable`。桥应答请求,但不决定哪些调用需要审批。 -应答者通过 [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 描述的桥反向映射归属 seam 进行路由,实现了[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) 所要求的每会话权限归属。 +应答者通过 [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 描述的桥反向映射归属 seam 进行路由,实现了[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) 要求的每会话权限归属。 #### 审计,以及模型看到什么 -`approval/asked` 和 `approval/decided` 是持久的仅日志事件。模型只看到发起方记录的 `tool/result`。每个被接受的请求追加一条匹配的决策,包括取消和被包含的应答者失败。 +`approval/asked` 和 `approval/decided` 是持久的仅日志事件。模型只看到发起方派生的已记录 `tool/result`。每个被接受的请求追加一条匹配的决策,包括取消和被容纳的应答者失败。 #### 实体与依赖 -`dsh-user-approval` 拥有固定的派发与审计机制;`dsh-tools` 发起请求,`dsh-acp` 应答。可替换的应答者作为监听器留在其通道拥有者插件中,因此三包能力拆分只会增加一个空的实现层。沙箱执行器仍然只负责传输,静态能力授权与交互式审批保持分离。 +`dsh-user-approval` 拥有固定的派发与审计机制;`dsh-tools` 发起请求,`dsh-acp` 应答。可替换的应答者作为监听器留在其通道拥有者插件中,因此三包能力拆分只会多出一个空的实现层。沙箱执行器仍然只负责传输,静态能力授权与交互式审批保持分离。 ### 测试 -- **单元/集成测试:** 覆盖先到先得的委托、fail-closed 默认值、格式错误和抛异常的应答者、取消竞争与迟到应答丢弃、观察者失败下的审计配对、不可绕过的 `'never'`、不同的工具拒绝原因,以及 ACP 每会话路由/结果映射。 -- **快照测试:** 通过沙箱升级的两个分支编排权限应答并固定 `'never'` 提示词加策略切换通知。没有组合应答者时钩子产生的 ask 仍作为 fail-closed 拒绝被覆盖。 +- **单元/集成测试:** 覆盖先到先得的委派、失败关闭默认值、畸形和抛异常的应答者、取消竞速与迟到应答丢弃、观察者失败时的审计配对、不可绕过的 `'never'`、不同的工具拒绝原因,以及 ACP 每会话路由/结果映射。 +- **快照测试:** 对沙箱升级的两个分支编排权限应答并固定 `'never'` 提示词加策略切换通知。无组合应答者时钩子产生的 ask 仍作为失败关闭拒绝被覆盖。 ## 延后 -- **`allow_always` 授权存储**——兑现持久授权意味着设计存储、范围标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只宣告一次性选项([沙箱 RFC](2026-07-06-sandbox.md) § 升级记录了开放的范围问题)。 -- **有组合应答者时录制的钩子产生的 ask**——升级录制了人类提示的协议格式(wire format),而当前钩子 fixture(测试前置数据)固定的是无服务拒绝;它们组合的生产者/应答者路径仍由单元测试覆盖。 -- **将子 agent 的审批路由到父会话**——`subagent-acp` 的子端自动应答自己的 `permission` 请求;将它们呈现给父端编辑器是独立的设计。 +- **`allow_always` 授权存储**:兑现持久授权意味着设计存储、作用域标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只展示一次性选项([沙箱 RFC](2026-07-06-sandbox.md) § Escalation 记录了开放的作用域问题)。 +- **有组合应答者时录制的钩子产生的 ask**:升级场景录制了人类提示的协议格式(wire format),而当前钩子 fixture(测试前置数据)固定的是无服务拒绝;二者组合的生产者/应答者路径仍由单元测试覆盖。 +- **将子 agent 的审批路由到父会话**:`subagent-acp` 的子侧自动应答自己的 `permission` 请求;将其呈现给父会话的编辑器是独立的设计。 ## 曾考虑的替代方案 -- **单个注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——白名单预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时默认拒绝和 HMR(热模块替换) dispose(资源释放);seam 的 JSDoc 用约定固定单决策槽语义,而非发明一个提供方注册表。 -- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会把发起 ask 的策略硬编码到 UI 插件中,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时机),且让钩子产生的 `ask` 决策没有共享机制。 -- **通用用户交互 seam(`ctx.userInteraction`)**:否决作为审批机制。两者共享骨架(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时默认拒绝、以及审计事件。因此审批不走已发布的 `packages/ui/user-interaction` / `ask_user_question` 引出路径——引出表单不是权限提示,自由文本应答不是封闭结果;如果两者未来趋同,共享提供方管道仍然开放。 -- **在 `dsh-tools` 中静态可选注入**:否决。vendor 的 cordis `Inject` 类型没有可选标志——对象形式将服务名映射到拦截配置,声明的 inject 会阻塞 fiber。`ctx.get('approval')` 是文档化的机会性消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测),按调用读取存在性,在 HMR 下无需额外机制即可正确降级。 -- **能力 seam 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seam(bash-local vs bash-sandbox)。这里服务体是固定机制,可变部分是留在各自通道拥有者中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。 -- **现在就提供 `allow_always`**:否决。协议可以表达它,但兑现它意味着设计授权存储、范围标识和撤销(§ 延后)。宣告一个 harness 无法兑现的选项只会制造注定失败的授权。 +- **单一注册提供方而非 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 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seam(bash-local vs bash-sandbox)。此处服务体是固定机制,可变部分是留在各自通道拥有者插件中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。 +- **现在就提供 `allow_always`**:否决。协议能表达它,但兑现它意味着设计授权存储、作用域标识和撤销(§ 延后)。展示 harness 无法兑现的选项只会制造注定失败的授权。 ## 后果 -- 只有 `allowed-once` 才会派发被询问的操作;缺失、拒绝、取消或应答失败的路径均拒绝。 +- 只有 `allowed-once` 才会派发被询问的操作;缺失、拒绝、取消或应答失败的路径一律拒绝。 - 会话归属路由提示、策略和审计事件,不跨越编辑器会话。 - 被接受的请求追加一对持久审计事件;模型只看到最终的工具结果。 -- 没有加载该服务的部署不会发出审批提示或审计事件,并在工具边界拒绝每个 `ask`。 +- 没有该服务的部署不产生审批提示或审计事件,在工具边界拒绝每一个 `ask`。 代价与已接受的局限: -- **两个急于决策的应答者争抢同一个槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者——通过约定缓解(每个部署一个终端应答者;仅对「决定或委托」门禁使用 `prepend`),而非事件总线不具备的优先级机制。 -- **生产环境的验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,以及沙箱升级通过其自身门禁——协议格式录制在沙箱示例的快照套件中,因此 seam 的真实覆盖率就是这一种组合,直到更多部署组合它。 -- **归属以 `Agent` 对象同一性为键。** 应答者通过桥现有的 WeakMap 解析会话;当前所有路径在 loop 和各 seam 之间传递同一个对象,但未来如果某个边界克隆或代理了 agent,桥会委托并默认拒绝——安全但静默无 UI——届时需要改用 session-id 匹配。 +- **两个急于决策的应答者竞争同一槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者。通过约定缓解(每个部署一个终端应答者;仅对「先决策或委派」门禁使用 `prepend`),而非事件总线不具备的优先级机制。 +- **生产环境验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,沙箱升级通过自己的门禁——协议格式录制在沙箱示例的快照套件中;因此在更多部署组合它之前,seam 的真实覆盖面就是这一种组合。 +- **归属以 `Agent` 对象标识为键。** 应答者通过桥已有的 WeakMap 解析会话;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent,桥会委派并失败关闭——安全,但静默无 UI——届时需要改用 session-id 匹配。 ## FAQ -- **在完全没有应答者的部署中(无头模式、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` 子端的自动应答是独立的;将子端的 ask 路由到父端编辑器已延后(§ 延后)。 -- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次自动拒绝仍然落一对审计事件。 +- **在完全没有应答者的部署中(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`;请求本身不携带参数副本。 +- **用户在哪里看到自己在批准什么?** 在工具调用本身:提示通过 `callId` 附着在已流式输出的调用上(包含参数),并添加发起方的人类可读 `reason`;请求本身不携带参数副本。 ## 先例 -本设计复用或对比的仓库内先例: +本设计复用或对照的仓库内先例: -- `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占位决策槽 waterfall 语义(先到先得、通过 `next()` 委托),应答者契约复用了它。 -- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 RFC](2026-06-30-hook-bridges.md) 发布了 `permissionDecision: ask`,即第一个生产者。 +- `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。 +- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 RFC](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。 - [拦截 seam RFC](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。 - [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)——应答者路由所经过的 `WeakMap<Agent, sessionId>` 归属 seam;[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。 - 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。 diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml index 352331b13d..0e418c8e57 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml @@ -3,4 +3,4 @@ # 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: 9d94496ffdcbc4c7df820581b02e3e075ec1c0be -2026-07-06-explicit-tool-order.zh.md: fa3a5bbf83115c25f87471b8a3847b9347d42934 +2026-07-06-explicit-tool-order.zh.md: 0b020f969299799289e18ed93c81db08cabc0b2d diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md index fa3a5bbf83..0b020f9692 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md @@ -1,50 +1,50 @@ # RFC:显式的模型侧工具顺序 -Status: implemented - [English](2026-07-06-explicit-tool-order.md) | 中文 +Status: implemented + ## 问题 -模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于彼此独立的插件在并发模块加载时的竞态。这一竞态导致 CI 和快照录制中产生不同的请求头。由于顺序影响请求字节、缓存和持久化的头部,需要一个显式的确定性策略。 +模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于相互独立的插件的并发模块加载。这种竞态在 CI 和快照录制中产生了不同的请求头。由于顺序影响请求字节、缓存和持久化的 header,因此需要一个显式的确定性策略。 ## 决策 -系统提示词组装拥有模型侧工具的权威顺序,正如它已经拥有 section 顺序一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略: +系统提示词的组装逻辑拥有模型侧工具顺序的权威定义,正如它已经拥有 section 顺序的权威定义一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略: -- 列表中已注册的工具取其列出的位置。 -- 列表中的名称没有对应的已注册工具,属于配置错误。形状错误(缺少 rest 条目或名称重复)在服务构造器中快速失败;未注册的名称在每次 `assemble()` 时拒绝——这是已注册工具集存在可供检查的最早时刻(工具插件在服务构造之后注册),也是唯一的通用时刻(注册随时可能变化;Cordis 没有「所有插件已加载」事件)。在已交付的 agent loop 下,第一个轮次在任何模型请求之前就会失败——确切的影响范围见下文「后果」。 -- 已注册但不在列表中的工具,插入到 `'<unlisted-tools>'` rest 条目(`TOOL_ORDER_REST`)处,在其他未列出的工具之间按名称字典序排列。 -- 任何已收集的工具不得使用 `TOOL_ORDER_REST` 作为其 `ToolSchema.name`;组装在排序之前就会拒绝该保留名称。 -- 列表必须恰好包含一个 rest 条目,且名称不得重复。 +- 列表中已注册的工具按列表位置排列。 +- 列表中的名称没有对应的已注册工具,属于配置错误。形状错误(缺少 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 从这个确定性列表出发;未被改变的顺序随后流入请求头、冻结请求和重建检查,无需循环特有的排序逻辑。 +`assemble()` 在 `system-prompt/assemble` waterfall(瀑布式事件)之前对提供方工具进行规范化排序,从源头消除注册顺序的差异。waterfall 从这个确定性列表开始;不变的顺序随后流入请求头、冻结的请求和重建检查,无需 loop 特有的排序逻辑。 -范围刻意收窄:本 RFC 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍可添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已要求监听器具有确定性(可重建性不变式会捕获在构建与回放之间表现不一致的监听器)。 +范围刻意收窄:本 RFC 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍然可以添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已经要求监听器是确定性的(可重建性不变式会捕获在构建与回放之间行为不一致的监听器)。 -配置传递沿用 `persona` 的先例,`toolOrder` 与它并列:应用配置(`dsh-stdio-demo`、`dsh-acp-demo`)接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节是关键的:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链上的每个 schema 都将默认值强制为 `undefined`。 +配置传递沿用 `persona` 的先例,`toolOrder` 与之并列:应用配置(`dsh-stdio-demo`、`dsh-acp-demo`)接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节至关重要:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链路上每个 schema 都将默认值强制为 `undefined`。 ## 曾考虑的替代方案 -- **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 不稳定),评审中不可见。 -- **插件依赖图的线性化**:该关系是偏序的,独立的工具插件之间不可比较;上述不稳定发生时偏序已完全满足。 -- **每个插件在工具贡献上设 `weight`**:将顺序分散到各插件中,仍需一个无人拥有的全局编号约定(section 的 `order` 分段已经展示了这种协调成本需要手工承担)。 -- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个被组装之外的更多消费方使用的成员存储;排序是 prompt 组合的关注点,而组装已经拥有 section 的组合策略。 -- **`LlmService` 配置 + 循环在记录头部前调用的 `orderTools()` 方法**:可行,但仅为在远处应用策略就增加了一个公开服务方法和一处循环改动;每个未来的请求组合者都必须记得调用。在列表诞生处规范化使无序列表不可表示,且零新增接口。 -- **在 `llm.stream()` 内部规范化**:在头部事件记录之后才运行(不稳定仍存在),且需要重建深度冻结的信封,静默地解除了重建不变式。 +- **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 抖动),评审中不可见。 +- **插件依赖图的线性化**:该关系是偏序的,独立的工具插件不可比较;抖动发生时偏序已完全满足。 +- **每个插件在其工具贡献上标注 `weight`**:将顺序分散到各插件中,仍需一个无人拥有的全局编号约定(section 的 `order` 分段已经展示了这种协调成本需要手工承担)。 +- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个成员存储,被组装之外的多方消费;排序是 prompt 组合的关注点,而组装逻辑已经拥有 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` 在加载中途触发(与工具注册竞态——正是本 RFC 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设一个执行点被判定值得接受较晚的失败时刻。 +- **启动时校验(由 `dsh-app-boot` 在 `loader.await()` 之后调用 `SystemPrompt.assertToolOrderSatisfied()`)**:能将错误配置变为启动时死亡而非首轮次失败,但代价是一个公开服务方法加上通用启动胶水对单个服务的结构耦合,且无法替代组装时检查(嵌入式调用者从不运行 app boot;注册在 boot 之后仍会变化)。也没有现成事件可以承载该检查:Cordis v4 没有 ready 类事件,`loader/entry-init`/`internal/status` 在加载中途触发(与工具注册存在竞态——正是本 RFC 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设置单一执行点被判定值得接受较晚的失败时刻。 ## 后果 -- 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转在结构上被消除,默认为字典序。 -- 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序出发;提供方注册顺序在该协作 seam 之前的任何地方都不可观测。 -- 步骤之间的纯工具重排只能表示为 `request/header` 的 `'fallback'` 快照(基于名称键的 `ToolsDelta` 无法表达它);在稳定的权威顺序下,这种重排在实践中不再发生,因此 fallback 路径仅作为安全阀保留。 -- `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 转发链传递,因此部署时在 app 配置中与 `persona` 并列设置;`dsh-llm` 和 agent loop 不受影响。 -- `toolOrder` 中拼写错误或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:循环在轮次内组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带消息,`agent/error` 镜像它,不开启步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(与仓库规则一致:显式配置引用不得被静默忽略——执行点在组装处,因为不存在更早的通用时刻)。 -- 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的列出名称相同。这防止哨兵值变成歧义的真实工具,并保持「从不丢弃工具」的排序契约。 +- 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转从结构上被消除,默认为字典序。 +- 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序开始;提供方注册顺序在该协作 seam 之前无处可观测。 +- 步骤之间的纯工具重排只能表示为 `request/header` 的 `'fallback'` 快照(按名称索引的 `ToolsDelta` 无法表达它);在稳定的权威顺序下,这种重排在实践中不再发生,因此 fallback 路径仅作为安全阀存在。 +- `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 的转发链传递,因此部署时将其放在 app 配置中 `persona` 旁边即可;`dsh-llm` 和 agent loop 无需改动。 +- `toolOrder` 中拼错或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带错误消息,`agent/error` 镜像该消息,不打开步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。 +- 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的已列名称相同。这防止哨兵值变成一个歧义的真实工具,并保持「从不丢弃工具」的排序契约。 ## 测试 -系统提示词测试覆盖字典序默认顺序、列出/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。循环测试固定跨注册排列的已记录和已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结请求,以及在未知配置名称下的平衡轮次失败(无步骤、无头部、无适配器调用)。快照回放仅在固定的 `text-turn` 头部中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。 +系统提示词测试覆盖:字典序默认顺序、列表/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。Loop 测试固定:跨注册排列的已记录与已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结的请求,以及在配置了未知名称时的平衡轮次失败(无步骤、无 header、无适配器调用)。快照回放仅在固定的 `text-turn` header 中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。 diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index c36f317c2f..03a68c9166 100644 --- a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -3,4 +3,4 @@ # 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: 7706190257b54730532e4aa46cc9c47453c59871 -2026-07-07-mcp-client-plugin.zh.md: 4d2ea8532afbf6160a98020b8cc480e1bf683981 +2026-07-07-mcp-client-plugin.zh.md: b5fee7eff7f12de5658f0a10c32cfeed71482fdf diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 4d2ea8532a..b5fee7eff7 100644 --- a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -harness 此前无法消费 MCP(Model Context Protocol)生态的工具。MCP 是工具服务器的新兴标准:GitHub、文件系统、数据库、代码搜索以及数百个社区服务器都通过 MCP 暴露工具。用户希望将 harness 指向一个或多个 MCP 服务器,让它们的工具以原生的模型可见工具形式出现,而无需为每个服务器编写胶水代码。 +harness 此前无法消费 MCP(Model 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()`")。基础设施已就绪,缺的是桥接插件。 +`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()`")。基础设施已就绪,缺的是桥接插件。 ## 决策 ### 包 -单个包 `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 三包拆分:可预见范围内不会有第二种 MCP 客户端实现,且约定是「不要预防性拆分」(见[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md))。 +单个包(package) `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 的三包拆分——可预见范围内不会有第二种 MCP 客户端实现,且约定是"不要预防性拆分"([能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md))。 ### SDK @@ -22,11 +22,11 @@ harness 此前无法消费 MCP(Model Context Protocol)生态的工具。MCP ### 范围 -仅 MCP 客户端(不含服务器端——ACP 已覆盖「将 harness 暴露为 agent」的角色)。仅桥接 **Tools**:Resources 和 Prompts 推迟(它们需要 harness 侧尚不存在的消费机制,且设计空间很大)。 +仅 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` 相同。 +命名空间插件(具名导出 `name`/`inject`/`Config`/`apply`,无 `export default`)。`inject: ['tools']`。每个 MCP 服务器对应 `cordis.yml` 中的一个插件实例——同一个包以不同配置加载 N 次,与 `dsh-tool-subagent` 相同。 ### 配置 @@ -54,7 +54,7 @@ interface StreamableHttpConfig { type Config = StdioConfig | StreamableHttpConfig ``` -`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具划定命名空间。它有意设计为用户配置,**不是**远端的 `serverInfo.name`:远端名称是不可信输入,跨部署不唯一(同一服务器的 prod 和 staging 实例报告相同名称),且可能在服务器升级时变化——这些都不得静默地重命名模型可见工具。多个活跃实例使用相同 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)同时也是缩短公开名称的旋钮。 +`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具提供命名空间。它有意设计为用户配置,而**非**远端的 `serverInfo.name`:远端名称是不可信输入、跨部署不唯一(同一服务器的生产和预发布实例报告相同名称)、且可能在服务器升级时变化——这些都不得静默重命名模型可见工具。多个活跃实例使用重复的 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)也是缩短公开名称的调节手段。 `cordis.yml` 用法示例: @@ -83,28 +83,28 @@ type Config = StdioConfig | StreamableHttpConfig ### 生命周期 -启动时从 `cordis.yml` 加载。HMR(`@cordisjs/plugin-hmr`)提供热替换:编辑 yml 条目会触发旧实例的 dispose(断开连接、注销工具),并创建新实例(连接、发现、注册)。目前不提供运行时动态 API。公开名称是 `(serverName, rawName)` 的纯函数,因此保持 `serverName` 不变的 HMR 替换会重建完全相同的模型可见名称——会话历史和权限规则保持有效——且添加或移除一个无关服务器绝不会重命名已有工具。 +启动时从 `cordis.yml` 加载。HMR(热模块替换)(`@cordisjs/plugin-hmr`)提供热替换:编辑 yml 条目触发旧实例的 dispose(资源释放)(断开连接、注销工具),并创建新实例(连接、发现、注册)。目前不提供运行时动态 API。公开名称是 `(serverName, rawName)` 的纯函数,因此保持 `serverName` 不变的 HMR 替换会重建完全相同的模型可见名称——会话历史和权限规则保持有效——而添加或移除不相关的服务器永远不会重命名已有工具。 ### 工具发现与注册 每个 MCP 工具有两个名称: -- `rawName`:MCP `Tool.name` 的原始值,仅在协议层(`tools/call`)使用。 -- `publicName`:在 `ToolRegistry` 中注册的全局唯一模型可见名称: +- `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__*`)。 +这种按服务器限定的形式是多服务器 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 和 description 原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 -2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性的名称意味着未变化的工具在重新同步后保持原名。 -3. 执行器闭包持有 `rawName`;公开名称从不发送给服务器,也从不被解析以恢复原始名称。 -4. 不提供 `presentCall`/`presentResult`:ACP 桥接的通用卡片回退负责渲染。 -5. 工具在系统提示词中是透明的:除名称本身外不添加 "[via MCP]" 之类的标注。 +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 标识永远不会折叠为同一个公开名称: +MCP 允许工具名最长 128 字符且可包含 `.`;DeepSeek 的函数名契约允许 `[A-Za-z0-9_-]` 且最多 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash,确保不同的 MCP 标识永远不会坍缩为同一个公开名称: ```typescript function publicToolName(serverName: string, rawName: string): string { @@ -118,97 +118,97 @@ function publicToolName(serverName: string, rawName: string): string { ### 名称冲突处理 -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`)。始终启用的命名空间从结构上杜绝冲突,而非在冲突发生时再处理: +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>__` 命名空间:部分生成被回滚(该服务器零工具注册),错误被醒目地记录。 +- 重复的 `serverName` 配置使后加载的实例在启动时失败(见配置一节)。 +- 服务器列出重复的工具名属于无效工具列表:同步抛出异常,上一代注册保持不变。 +- 替换期间的注册表冲突只可能意味着外部工具占据了该服务器的 `mcp__<serverName>__` 命名空间:部分代注册被回滚(该服务器零工具),并以醒目日志记录错误。 工具永远不会被静默跳过;哪些工具可用永远不取决于插件加载顺序。 ### 命名不变式 -1. 每个 MCP 工具有稳定标识 `(serverName, rawName)`;每个活跃标识恰好对应一个公开名称。 +1. 每个 MCP 工具拥有稳定标识 `(serverName, rawName)`;每个活跃标识恰好对应一个公开名称。 2. 公开名称是确定性的、全局唯一的,且满足 DeepSeek 64 字符 `[A-Za-z0-9_-]` 契约。 3. MCP `tools/call` 始终接收原始的 raw name。 -4. 连接、断开或重新同步一个无关服务器,绝不会重命名已有工具。 -5. 注册顺序绝不决定哪个工具可用。 +4. 连接、断开或重新同步不相关的服务器永远不会重命名已有工具。 +5. 注册顺序永远不决定哪个工具可用。 ### 工具执行 -为来自同一 MCP 服务器的所有工具提供统一的 `execute` 处理器: +为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器: -1. 解析 `rawName`(执行器闭包持有),以配置的超时调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称从不发送给服务器。 +1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。 2. 映射结果: - - 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 不带分隔符,多个块会丢失块间边界)。 - - `image` 内容块 → 丢弃并记录 `ctx.logger.warn`(harness 没有图片内容块类型;见 [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md))。 + - 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 无分隔符,多块会丢失块间边界)。 + - `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md))。 - `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`)。 -3. 取消:`exec.signal`(来自 agent loop 的 cancel)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`。 +3. 取消:`exec.signal`(来自 agent loop(智能体循环)的取消)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`。 ### 子进程环境(stdio 传输) -复用 `dsh-subagent-acp` 的 `buildChildEnv` + `SENSITIVE_ENV_PATTERN` 清洗逻辑:过滤环境变量(剥离匹配 `/KEY|SECRET|TOKEN/i` 的凭证形变量),然后将 `config.env` 覆盖在上面。显式配置的 env 不受清洗影响。 +复用 `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。 +3. 恢复:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。 -这与 ACP subagent 的模式一致:「崩溃即终态,报告错误,清理资源,不重试。」 +这与 ACP subagent 模式一致:"崩溃即终态,报告错误,清理资源,不重试。" ## 曾考虑的替代方案 -### MCP 服务器端(向外部 MCP 客户端暴露 harness 工具) +### MCP Server 端(将 harness 工具暴露给外部 MCP 客户端) -推迟。ACP 桥接已将 harness 暴露为 agent 服务器。再加一层 MCP 服务器会用不同协议重复这一功能,而用户的首要需求是消费外部工具,而非暴露自身工具。 +延后。ACP 桥接已将 harness 暴露为 agent 服务器。再加一层 MCP server 会以不同协议重复这一功能,而用户的首要需求是消费外部工具,而非暴露自身工具。 -### 能力 seam 三包拆分(接口 / 实现 / 消费方) +### 能力 seam 三包拆分(interface / impl / consumer) -否决。可预见范围内不会有替代的 MCP 客户端实现:MCP 只有一个协议、一个 SDK。约定是「在第二种实现出现之前不要预防性拆分」。 +否决。可预见范围内不会有替代的 MCP 客户端实现——MCP 只有一个协议、一个 SDK。约定是"不要预防性拆分",直到出现第二种实现。 ### 指数退避自动重连 -v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,未来可作为 `reconnect: boolean` 配置项加入。 +v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,可在未来作为 `reconnect: boolean` 配置项添加。 ### 桥接 Resources 和 Prompts -推迟。Resources 需要 harness 侧的机制来决定何时注入内容(系统提示词?按需?模型触发?)。Prompts 需要 harness 目前缺少的「prompt 模板」概念。两者都需要独立设计;Tools 是高价值、低风险的起点。 +延后。Resources 需要 harness 侧的机制来决定何时注入内容(系统提示词?按需?模型触发?)。Prompts 需要 harness 尚不具备的"提示词模板"概念。两者都需要独立设计;Tools 是高价值、低风险的起点。 ### 原始模型可见工具名加可选 `toolPrefix` -否决。这是最初的提案,建立在「大多数 MCP 服务器已在工具名中使用语义前缀(如 `github_create_issue`)」的前提上。该前提不成立:官方 GitHub 服务器发布的是 `create_issue`,参考文件系统服务器是 `read_file`,Sentry 是 `search_issues`——且上述微软调研表明冲突在生态规模下很常见。冲突时再加前缀(或 warn-and-skip)还会使可用工具集取决于插件加载顺序,且添加一个无关服务器可能静默重命名已有工具——在对话中途使会话历史和权限规则失效。所有被调研的多服务器 agent 产品都不使用裸名称。 +否决。这是最初的提案,基于"大多数 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 将来增加源感知的命名空间,届时可作为命名策略变更重新考虑去掉字面前缀。 +v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 harness 工具分离,也丧失了 MCP 全局策略匹配模式(`mcp__*`)。前缀仅多花 5 个字符;`mcp__<server>__<tool>` 拼写与 Claude Code 和 Codex 一致,最大化模型的熟悉度。如果 ToolRegistry 未来引入源感知命名空间,届时可作为命名策略变更重新考虑去掉字面前缀。 -### 从服务器公告的 `serverInfo.name` 推导命名空间 +### 从服务器公告的 `serverInfo.name` 派生命名空间 否决。远端名称不可信、跨部署不唯一、升级时可变;工具标识和权限规则不得静默跟随它。命名空间是本地配置。 ### 在工具结果中保留多个 TextBlock -否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 展平为协议格式(wire format)时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性 bug。所有现有工具返回单个 TextBlock;MCP 桥接遵循同样做法。 +否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 扁平化为协议格式(wire format)时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性缺陷。所有现有工具返回单个 TextBlock;MCP 桥接遵循同一做法。 ## 测试 -覆盖按层级命名;每个行为放在能表达它的最低成本层级。 +覆盖率按层级命名;每个行为放在能表达它的最低成本层级。 -- **单元测试**(`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`,无需密钥):使用仓库内 fixture 服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem` 通过 stdio 运行真实 MCP 协议,以及通过进程内 `StreamableHTTPServerTransport` 服务器运行 Streamable HTTP——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose(资源释放)。 -- **快照**:刻意不做。MCP 工具不引入新的 transcript 渲染面——它们注册为原始 `ToolDefinition`,通过 ACP 桥接的通用卡片回退渲染,而桥接的单元测试套件已固定了该行为(`packages/ui/acp/tests/stream-update.spec.ts`)。将 MCP 服务器加入快照示例的 `cordis.yml` 会改变已固定的 `text-turn` 系统提示词 fixture(迫使每条录制的 golden 都需要带密钥重新录制),并使每次回放依赖于 spawn 一个外部 MCP 服务器进程——而新增的渲染行为为零。如果后续变更为 MCP 工具引入专属的渲染意图,该变更届时自行命名其快照覆盖。 +- **单元测试**(`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(迫使每条录制的 golden 都需要带密钥重新录制),且使每次回放依赖于 spawn 外部 MCP 服务器进程——而新增渲染行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。 ## 后果 -- 每个 MCP 服务器只需一条 `cordis.yml` 条目即完成集成:`serverName: filesystem` 加一条 stdio 命令(或一个 Streamable HTTP URL),就能把 `mcp__filesystem__read_file` 放入模型的工具列表,可调用,协议层使用原始的 `read_file`。 -- 公开名称是会话历史与权限/配置界面的一部分;命名算法是由测试固定的 v1 契约,发布后修改它是破坏性变更。 -- `mcp__<serverName>__` 限定符在每个名称上消耗 token。已接受:description 和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配形状(`mcp__*`、`mcp__github__*`)。 -- **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。 -- **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的 description、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的责任。 -- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号可能卡住 dispose。Cordis fiber 的 dispose 有有界静默期;卡住的传输层最终会在框架层面超时。 -- 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置项作为未来工作保持开放。 +- 每个 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` 配置作为未来工作保持开放。 diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml index fddf23cccb..038349fd78 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-session-prefix.md: ffa0fecb86e84ed64d45b24e1b6943d421757fb2 -2026-07-07-session-prefix.zh.md: 292c74fac75d8f2c29628fc5e90c72dadcaf4fdb +2026-07-07-session-prefix.zh.md: ca38ebf337e16f4f2368ca74e394fcc1031fbf1f diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md index 292c74fac7..ca38ebf337 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md @@ -1,4 +1,4 @@ -# RFC:会话前缀——置于派生历史之前的仅请求消息 +# RFC:会话前缀——派生历史之前的仅请求消息 Status: implemented @@ -6,39 +6,39 @@ Status: implemented ## 问题 -插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在这个 seam 出现之前,harness 只提供两个归属位置,但对这类内容来说两个都不对。系统提示词是一个渲染后的单字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对对话消息与系统文本的权重处理不同。持久化历史(`agent.inject()`、会话开始时的 `context/message`)会让开场内容变成永久记录:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会把它以陈旧状态烘焙进去,resume 无法刷新它——一份在会话诞生时捕获的目录会比它所描述的世界活得更久。 +插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前,harness 为这类内容提供了两个归属位置,但两者都不合适。系统提示词是一个渲染后的单一字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(`agent.inject()`、会话启动时的 `context/message`)使开场内容变为永久:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会将其以陈旧状态固化,resume 也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。 -显而易见的第三个选项——让插件在请求发出时编辑 `messages`——被[可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md) 禁止:每个由循环构建的请求都是会话日志的纯函数,因此承载开场内容的通道必须精确记录它所发送的内容。缺失的是一个带持久记录的仅请求消息通道。 +显而易见的第三种选项——让插件在请求发出途中编辑 `messages`——被[可重建请求 RFC](../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()]`,在协议格式上产生注册顺序)。循环([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,延迟到该实例首次 `agent/pre-step` 之前;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发送的每个请求中置于**整个**派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 +`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 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 三个属性承载了这一设计: -- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 RFC 已经为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + 边界派生`;未记录的前缀无法到达协议格式。 -- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存在构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 是一个新实例:它重新组合,任何漂移都可归因地落在 `'resume'` header 快照上。这就是该 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——[拦截 seam RFC](2026-06-30-interception-seams.md)),每条都是一次性持久化的 `context/message`,之后被前缀缓存覆盖。 -- **在压力门禁之前组合。** 组合先于实例的首次 `agent/pre-step`,且 seam 将组合值传递下去:`agent/pre-step` 携带 `sessionPrefix` 参数,`CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` 将其计入 token 压力估算——如果门禁读取的是上一个实例折叠后的前缀,那么在一个贡献者增长了的 resume 或 fork 实例的首步上会低估压力,跳过压缩并发出超窗口的首请求。组合过程中如果 cancel/dispose 落入 waterfall 内部,组合结果被丢弃、永不缓存:一个感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活跃 signal 下重新组合。 +- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 RFC 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + 边界派生`;未记录的前缀无法到达协议格式。 +- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 产生新实例:它重新组合,任何漂移都可追溯地落在 `'resume'` header 快照上。这就是本 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——[拦截 seam RFC](2026-06-30-interception-seams.md)),每条都是一次性支付的持久 `context/message`,之后被前缀缓存覆盖。 +- **在压力门禁之前组合。** 组合先于实例的首次 `agent/pre-step`,且 seam 将组合值透传:`agent/pre-step` 携带 `sessionPrefix` 参数,`CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` 将其计入 token 压力估算。如果改为让门禁读取上一个实例折叠后的前缀,则在 resume 或 fork 后的实例中(贡献者可能已增长),门禁会低估压力、跳过压缩,发出超窗口的首个请求。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。被 cancel/dispose 中断的组合(中断落在 waterfall 内部)会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。 -由于组合在边界快照之前运行,组合监听器的会话追加会加入**当前**请求的派生历史。压缩在结构上无法触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。 +由于组合在边界快照之前运行,组合监听器的会话追加会加入**当前**请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。 ## 测试 -[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了无 header 增量时的组合一次复用、前置顺序、空前缀省略、不可变性,以及组合先于 pre-step;[取消测试](../../../../packages/core/agent-loop/tests/cancel.spec.ts)固定了丢弃与重新组合。会话编解码器、不变式和压缩测试覆盖 header 往返、请求重建和前缀感知的压力计算。快照规范化保留前缀计数,而[固定 header 场景](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md)拥有内容,默认示例保持无前缀。不需要前缀专属的 e2e 测试,因为该 seam 是确定性的且与提供方无关;带密钥的[请求缓存 e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) 覆盖了其缓存经济性。 +[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了以下行为:无 header delta 时的组合一次复用、前置插入顺序、空前缀省略、不可变性,以及组合先于 pre-step;[取消测试](../../../../packages/core/agent-loop/tests/cancel.spec.ts)固定了丢弃与重新组合。会话编解码器、不变式和压缩测试覆盖 header 往返、请求重建与前缀感知的压力核算。快照归一化保留前缀计数,[固定 header 场景](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md)拥有内容,默认示例保持无前缀。无需前缀专属的 e2e 测试,因为该 seam 是确定性的且与提供方无关;带密钥的 [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) 覆盖了其缓存经济性。 ## 曾考虑的替代方案 -- **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:每个请求触发一次 waterfall,贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入静默漂移——除非每步记录一个 header 增量,否则没有东西将其锚定到日志——而 `after` 槽位位于不断增长的历史之后,其 token 在每个请求中重新支付,且其后的所有内容不可缓存。与各替代方案对比衡量,当前每种更新模式都能由持久追加更廉价地服务(支付一次,此后缓存读取),唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。 -- **系统提示词分区**(`system-prompt/assemble`):对此类内容否决。组装渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带 header 增量),而开场内容需要的是按实例冻结的语义。 -- **持久化历史开场**(会话开始时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处回放、可被压缩、跨 resume 陈旧。 -- **按轮次而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制每次变化产生一个 header 增量,且它每次触发都会破坏提供方缓存;合理的刷新点是实例边界,`'resume'` 快照已经在那里可归因地记录漂移。 -- **在首请求时延迟组合,让压缩读取折叠后的 header**(首次合入时的形态):评审中被取代。折叠值只从实例的第二个请求起才与活跃前缀匹配,因此在 resume/fork 实例的首步上,压力门禁读取的是**上一个**实例的前缀,可能低估压力。在首次 pre-step 之前组合并通过 seam 传递活跃值,使估算在每一步都精确。 -- **承载前缀的专用会话事件**:否决。header 事件在设计上就是请求的非历史记录;第二个事件会成为同一事实的第二个归属,以及又一个需要保持完整的编解码器。 +- **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:一个每请求触发的 waterfall,贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入静默漂移——除非每步记录一个 header delta,否则没有东西将其锚定到日志;`after` 槽位位于不断增长的历史之后,其 token 在每个请求中重复支付,且其后的所有内容不可缓存。对照各替代方案衡量,当前所有更新模式都能通过持久追加更廉价地满足(支付一次,此后缓存读取),而唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。 +- **系统提示词分段**(`system-prompt/assemble`):对此类内容否决。assembly 渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带 header delta),而开场内容需要按实例冻结的语义。 +- **持久化历史开场**(会话启动时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、跨 resume 陈旧。 +- **按轮次组合而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制每次变化都产生 header delta;且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界,`'resume'` 快照已在那里可追溯地记录漂移。 +- **在首次请求时惰性组合,让压缩读取折叠后的 header**(最初合并时的形态):评审中被取代。折叠值仅从实例的第二个请求起才与活前缀匹配,因此在 resume/fork 后的实例首步,压力门禁读取的是**上一个**实例的前缀,可能低估压力。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。 +- **专用会话事件承载前缀**:否决。header 事件按设计就是请求的非历史记录;第二个事件会为同一事实提供第二个归属,并多出一个需要保持完整的编解码器。 ## 后果 -- `agent/pre-step` 和 `CompactService.compactIfNeeded` 携带 `sessionPrefix` 参数:每个 pre-step 监听器和压缩后端都能看到真实的每实例值(所有仓库内实现在同一个变更中更新,遵循预发布立场)。 -- 内容在会话中途变化的贡献者不会被重新读取,直到下一个实例——这是设计意图。需要会话中途目录更新的部署应将变更通知路由到仅追加历史通道,支付一条持久化 `context/message`。 -- 被放弃的 `after` 槽位使请求尾部没有仅请求通道;仓库中没有任何东西需要它,且加回它会重新引入该设计旨在避免的每步重复支付成本。 -- `request/header-delta` 的 `messagePrefix` 分支(整数组替换,空数组编码向缺失的过渡)为编解码器完备性而存在;循环从不行使它,因为缓存的前缀在实例内不可变。 -- 空组合是规范的缺失状态:无贡献者的部署不记录额外 header 字节,其请求就是裸派生。 +- `agent/pre-step` 与 `CompactService.compactIfNeeded` 携带 `sessionPrefix` 参数:每个 pre-step 监听器和压缩后端都能看到真实的按实例值(所有仓库内实现在同一个变更中更新,遵循预发布立场)。 +- 贡献者的内容在会话中途变化时,直到下一个实例才会被重新读取——这是设计意图。需要会话中途目录更新的部署,应将变更通知路由到仅追加历史通道,支付一条持久 `context/message`。 +- 被放弃的 `after` 槽位意味着请求尾部附近没有仅请求通道;仓库中没有任何功能需要它,且恢复它会重新引入本设计旨在避免的每步重复支付成本。 +- `request/header-delta` 的 `messagePrefix` 分支(整数组替换,空数组编码向缺失的过渡)为编解码器完整性而存在;循环从不触发它,因为缓存的前缀在实例内不可变。 +- 空组合即为规范缺失:无贡献者的部署不记录额外的 header 字节,其请求就是裸派生。 diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml index 03817c1073..5c62772145 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml @@ -3,4 +3,4 @@ # 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: 04d5d077a42b54ca7dc04a1efc9ea2f4034b642b -2026-07-08-repeat-tool-guard.zh.md: e20bd06a6902f9fadb77a90e719aaf703d7067cc +2026-07-08-repeat-tool-guard.zh.md: 917d958ca1019eb464b72b0201219de9dde7f658 diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md index e20bd06a69..917d958ca1 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md @@ -6,32 +6,32 @@ Status: implemented ## 问题 -模型陷入循环时会反复发出参数逐字节相同的工具调用——重新运行一个失败的 grep、重新读取一个未变化的文件、轮询一个已经给出答案的命令——每一轮往返都消耗 token、挂钟时间和(对付费 API 而言)金钱,却不带来新信息。harness 目前没有任何机制能察觉这一点:循环没有步骤预算,没有插件追踪调用重复,模型只有在碰巧自行改变行为时才能脱困。这种失败模式真实存在且易于检测——[pi-repeat-tool-guard](https://github.com/Kingwl/pi-repeat-tool-guard) 正是将此作为 pi coding-agent 扩展发布的:统计连续相同调用次数,超过阈值后追加一条 `<system-reminder>` 告知模型停止重复、改变策略。 +模型陷入循环时,会以字节级相同的参数反复发起同一个工具调用——重新运行一条失败的 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 RFC](2026-06-30-interception-seams.md) 赋予 `tools/post-execute` 一种正式途径,可以在已完成的调用上附加面向模型的上下文;循环缓冲并注入该上下文,保持调用/结果的邻接关系;注入的上下文是一条已记录的 `context/message`——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺的只是插件本身。 +harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 seam RFC](2026-06-30-interception-seams.md) 赋予 `tools/post-execute` 一种经过认可的方式,将面向模型的上下文附加到已完成的调用上;循环缓冲并注入该上下文,同时保持调用/结果的邻接关系;注入的上下文是一条已记录的 `context/message`——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺少的只是插件本身。 ## 决策 -守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻塞或改写调用;模型自行决定是否换一种方式重试或结束。 +该守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻止或改写调用;模型自行决定是换种方式重试还是结束。 -该插件为 `@deepseek-ai/dsh-repeat-tool-guard`,位于 `packages/guard/repeat-tool-guard/`,开辟 `guard/` 分组用于循环卫生插件(单包分组有先例:[todo-write RFC](2026-06-29-todo-write-tool.md) 发布了 `todo/tool-todo`)。它注册三个监听器,所有状态保存在以 `AgentId` 为键的插件局部 map 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent 的调用(subagent 运行在同一 context 上),因此按 agent 分键是正确性要求,而非锦上添花。 +插件为 `@deepseek-ai/dsh-repeat-tool-guard`,位于 `packages/guard/repeat-tool-guard/`,开辟 `guard/` 分组用于循环卫生插件(单包(package)分组有先例:[todo-write RFC](2026-06-29-todo-write-tool.md) 发布了 `todo/tool-todo`)。它注册三个监听器,所有状态保存在以 `AgentId` 为键的插件局部 map 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent(智能体)的调用(subagent 运行在同一个 context 上),因此按 agent 分键是正确性要求,而非锦上添花。 -- **`tools/post-execute`(waterfall)**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要 pending map 仅因其 `tool_call`/`tool_result` 钩子是独立事件)。它始终通过 `next()` 委托,当命中阈值时,将提醒折叠到下游决策的 `additionalContext` 上——这正是[钩子桥接](2026-06-30-hook-bridges.md)已在使用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非 `tools/pre-execute`,是因为 post-execute 也会为被拒绝的调用触发(`ToolRegistry.execute` 将 deny 路由到同一流水线),而模型反复锤击一个被拒绝的调用恰恰是值得打破的循环。 +- **`tools/post-execute`(waterfall)**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要它,仅因为其 `tool_call`/`tool_result` 钩子是分开的事件)。它始终通过 `next()` 委托,当命中阈值时,将提醒折叠到下游决策的 `additionalContext` 上——这正是[钩子桥接](2026-06-30-hook-bridges.md)已采用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非 `tools/pre-execute`,因为 post-execute 也会为被拒绝的调用触发(`ToolRegistry.execute` 将 deny 路由到同一条流水线),而模型反复敲击一个被拒绝的调用恰恰是值得打破的循环。 - **`agent/prompt-submit`(waterfall)**——纯重置钩子:通过 `next()` 委托,清除提交 agent 的链。用户介入改变了上下文;跨越介入的重复不是循环。 -- **`agent/status`(emit)**——在 `disposed` 时丢弃该 agent 的状态,限制 map 在 harness 生命周期内的增长。 +- **`agent/status`(emit)**——在 `disposed` 时丢弃该 agent 的状态,使 map 在 harness 生命周期内有界。 ### 检测语义 -链的键为 `(tool name, canonical arguments)`;与前一次被追踪的调用相同则递增该 agent 的连续计数器,不同则重置为 1。规范化方式为深度键排序加 `JSON.stringify`:`ToolExecution.arguments` 按构造即为循环中 `JSON.parse` 的输出(或参数 JSON 格式错误时的原始字符串回退,其本身也是可比较的值),因此 pi 原版对 bigint/循环引用/`undefined` 的处理在此没有输入,被有意去除。 +链的键是 `(tool name, canonical arguments)`;与前一个被追踪调用相同的调用递增该 agent 的连续计数器,不同的被追踪调用将其重置为 1。规范化方式为深度键排序加 `JSON.stringify`:`ToolExecution.arguments` 按构造就是循环中 `JSON.parse` 的输出(或格式错误的参数 JSON 的原始字符串回退,其本身也是可比较的值),因此 pi 原版对 bigint/循环引用/`undefined` 的处理在此没有输入,被有意去除。 -两条刻意的规则,均记录在[包 README](../../../../packages/guard/repeat-tool-guard/README.md) 中,因为它们是读者不看文档会猜测的行为: +两条刻意的规则,均记录在[包 README](../../../../packages/guard/repeat-tool-guard/README.md) 中,因为它们是读者否则只能猜测的行为: -- **未追踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增也不重置计数器,因此 `grep X → todo_write → grep X` 在 `todo_write` 被排除时仍计为两次连续的 `grep X`。这正是排除有用的原因——夹在循环中的记账工具不得洗白循环——也是 pi 扩展的(未文档化的)语义,有意保留并写明。 +- **未追踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增也不重置计数器,因此 `grep X → todo_write → grep X` 在 `todo_write` 被排除时仍计为两次连续的 `grep X`。这正是排除功能有用的原因——穿插在循环中的簿记工具不得为循环洗白——也是 pi 扩展的(未文档化的)语义,有意保留并明确写下。 - **没有 agent 的调用被忽略。** 直接调用 `ctx.tools.execute()` 的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的 `AgentId`。 ### 提醒投递 -提醒使用 `additionalContext` 并标注插件来源,保留原始 `tool/result`。首次阈值发出简短提示;后续阈值包含工具名、计数和有长度上限的参数预览,而比较仍使用完整的规范化字符串。已有的下游上下文在守卫的 source 下拼接,因为 `HookContext` 支持单一 source。 +提醒使用带插件 source 的 `additionalContext`,保留原始 `tool/result`。第一个阈值发出简短提示;后续阈值包含工具名、计数和有界的参数预览,而比较仍使用完整的规范化字符串。已有的下游上下文在守卫的 source 下拼接,因为 `HookContext` 只支持一个 source。 ### 配置 @@ -45,32 +45,32 @@ harness 已经具备 pi 扩展所用的全部 seam,且更好:[拦截 seam RF argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder ``` -`thresholds` 在加载时校验,空列表、非整数、小于 2 的值或重复值都会抛出异常——配置错误大声失败,取代 pi 原版的静默回退到默认值。`include`/`exclude` 条目支持 `*` 通配符。模式是对调用时实际存在的工具名的谓词,而非对注册表条目的引用,因此匹配不到任何当前已注册工具的条目不是错误——与 `toolOrder` 的引用检查不同,`exclude: [mcp_*]` 在未加载 MCP 工具的部署中必须保持有效。 +`thresholds` 在加载时校验,遇到空列表、非整数、小于 2 的值或重复项时抛出异常——配置错误快速失败,取代 pi 原版的静默回退到默认值。`include`/`exclude` 条目支持 `*` 通配符。模式是对调用时实际存在的工具的谓词,而非对注册表条目的引用,因此匹配不到当前已注册工具的条目不是错误——与 `toolOrder` 的引用检查不同,`exclude: [mcp_*]` 在未加载 MCP 工具的部署中也必须保持有效。 ## 测试 -- **单元测试:** 使用脚本化适配器的真实循环覆盖计数与重置规则、未追踪透明性、dispose 清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游阻塞或替换决策,达到逐文件 100% 覆盖率。 -- **快照测试:** keyless 的 `repeat-tool-guard` 场景发出五次相同的 `todo_write` 调用,将第三次的温和提醒和第五次的详细提醒固定在 ACP 输出和会话日志中。该插件在实时示例中加载,但在其他场景中保持静默。 -- **E2e:** 无;该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。 +- **单元测试:** 使用脚本化适配器的真实循环,覆盖计数与重置规则、未追踪透明性、dispose(资源释放)清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游 block 或 replacement 决策,达到逐文件 100% 覆盖率。 +- **快照测试:** keyless 的 `repeat-tool-guard` 场景发起五次相同的 `todo_write` 调用,在 ACP 输出和会话日志中固定第三次调用的温和提醒与第五次调用的详细提醒。该插件在实时示例中加载,但在其他场景中保持静默。 +- **E2e 测试:** 无。该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。 ## 曾考虑的替代方案 -- **将提醒追加到工具结果中**(`accept` 并替换 `content`——pi 扩展的机制,它修改结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的 `tool/result` 对工具实际返回的内容撒谎,而 `additionalContext` 正是为 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/` 先例表明每个插件家族用一个小型专属分组。 +- **将提醒追加到工具结果中**(以替换 `content` 的方式 `accept`——pi 扩展的机制,它修补结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的 `tool/result` 对工具实际返回的内容撒谎,而 `additionalContext` 的存在正是作为 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 生产者在同一次调用上附加上下文时,折叠在守卫的 `source` 下拼接;插件间的顺序遵循监听器注册顺序。该 seam 无法表示混合来源——这是继承自 `HookContext` 的限制,不属于本插件。 +- 提醒在设计上是建议性的:有意重复相同调用的幂等轮询模式仍会在超过阈值后收到提示,减压阀是配置(`thresholds`、`exclude`)加上明确允许「在已收集足够证据时结束」的提醒文本。每次触发在下一次请求中增加提醒 token 的开销;阈值限制了触发频率。 +- 链状态仅存于内存:从持久化恢复的会话以全新的链开始,因此跨越恢复的循环比实时循环更晚收到提醒——可以接受,守卫是启发式提示而非已记录的不变式,持久化计数器状态带来的收益不值得其复杂度。 +- 当多个 post-execute 生产者在同一次调用上附加上下文时,折叠在守卫的 `source` 下拼接;插件间的顺序遵循监听器注册顺序。该 seam 无法表示混合来源——这是继承自 `HookContext` 的限制,不归本插件所有。 -## 延后 +## 延后事项 -- 上下文压缩(compaction)不重置链:压缩后的历史改变了模型所见,但重复风险通常在压缩后仍然存在。 -- 在高阈值升级为 `block` 未实现;`PostToolDecision` 已支持此选项,待证据出现后可启用。 -- subagent 的链按 agent 隔离;在出现具体需求之前不引入共享机制。 +- 压缩(compaction)不重置链:压缩后的历史改变了模型所见的内容,但重复风险通常在压缩后仍然存在。 +- 在高阈值升级为 `block` 未实现;`PostToolDecision` 已支持此选项,待证据到来时启用。 +- subagent 的链按 agent 隔离;在出现具体用例之前不提供共享机制。 diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index e667e7a029..ba1e1f60be 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -3,4 +3,4 @@ # 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: 62b97dc4bdbd0e0b5b1f67f77c763065c79964ed -2026-07-08-self-referential-cordis-toolset.zh.md: ce220d256dbb3d43514702e57e71728fdc82a788 +2026-07-08-self-referential-cordis-toolset.zh.md: 44648d7a3f195f2dc84121c0d014e5193484b106 diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index ce220d256d..44648d7a3f 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -1,82 +1,82 @@ -# RFC:自引用 Cordis 工具集 - -Status: implemented +# RFC:自引用 cordis 工具集 [English](2026-07-08-self-referential-cordis-toolset.md) | 中文 +Status: implemented + ## 问题 -本 harness 中的一切都是 Cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。把这种能力交给模型值得探索——一个能审视并修改自身运行时的自引用 agent——但它同时引出三个正确性问题,而本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制。 +本 harness 中的一切都是 cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。赋予模型这种能力值得探索——一个能审视并修改自身运行时的自引用 agent——但这同时引发三个正确性问题,本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制。 -第一,模型编写的注册必须在注册发生时就被校验:格式错误的工具 schema 必须在注册时失败,而非等到后续请求尝试将其组装进提示词时才暴露。第二,模型编写的代码需要调用它从未见过源码的服务 API——猜测方法签名,更糟的是猜测返回值形状,会耗费大量盲目试探步骤。第三,模型挂载的一切都必须完全可 dispose(资源释放):模型可以按需释放,宿主插件重载时普通的插件生命周期也能释放,否则长会话会积累遗留的监听器和工具。 +第一,模型编写的注册必须在注册发生时就完成校验:格式错误的工具 schema 必须在注册时失败,而不是等到后续请求尝试将其组装进提示词时才报错。第二,模型编写的代码需要调用它从未见过源码的服务 API——靠猜测方法签名、更糟糕的是猜测返回值结构,会消耗大量盲目试探的步骤。第三,模型挂载的一切都必须完全可释放:模型可以按需释放,普通的插件生命周期在宿主插件重载时也会释放,否则长会话会积累遗留的监听器和工具。 ## 决策 -该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布——一个新的顶层 `packages/cordis/` 分组——并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作模型自身运行其中的活跃 Cordis 运行时:审视它、向其中挂载模型编写的插件、再将它们 dispose。 +该工具集以 [`@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 等同,既不是安全边界,也不是产品默认配置。 +vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:一个挂载可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 ### 三个工具 | 工具 | 契约 | |---|---| -| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则返回全部段落)。从不修改状态。 | -| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数体);代码必须 `return` 一个 Cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新生成的 id(`dyn-1`、`dyn-2`、……)追踪。 | -| `cordis_unmount` | 按 id dispose 一个动态挂载,并等待 disposal 达到静止——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 | +| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。从不产生变更。 | +| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数的函数体);代码必须 `return` 一个 cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新 id(`dyn-1`、`dyn-2`……)跟踪。 | +| `cordis_unmount` | 按 id 释放一个动态挂载,并等到释放达到静止状态后才返回——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 | -`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber,非活跃的 owner 会被标记)、`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`(挂载表:id、名称、状态、提供的服务、等待的服务)、`api`(来自生成目录的活跃服务签名及其引用的类型形状)、`events`(harness 事件及其分发模式和签名)。面向模型的工具描述携带模型在调用时所需的操作规则;[生成的工具目录](../../../tool-catalog.md)是其完整渲染。 +`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber,非活跃的所有者会被标记)、`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`(挂载表:id、名称、状态、提供的服务、等待的服务)、`api`(来自生成目录的活跃服务签名及其引用的类型形状)和 `events`(harness 事件及其分发模式和签名)。面向模型的工具描述携带了模型在调用时所需的操作规则;[生成的工具目录](../../../tool-catalog.md)是其完整呈现。 ### 沙箱语义 -挂载代码作为异步函数体在一个新的 vm realm 中运行。其文档化的接口面将文件、网络、进程和定时器访问引导至 Cordis 服务,使挂载保持可审视和可 dispose。宿主 realm 的辅助手段仍使 Node 逃逸成为可能,与信任姿态一致。`vmTimeoutMs` 仅约束同步执行部分。 +挂载代码以异步函数体的形式在一个新的 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` 特性探测保持惰性而非触发抛出异常的访问器。 +沙箱全局变量刻意精简:一个带标签的直写 `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 的 JSON,并在记录日志前校验 `ToolExecuteReturn` 形状。挂载的插件接收一个白名单上下文门面,而非原始或直通的 `Context`;框架管道和以 context 为值的返回会被拒绝。服务读取要求声明 `inject`,保持 Cordis 的激活和卸载语义。`ctx.tools.get` 仅暴露 schema 视图,使挂载代码无法绕过 `ToolRegistry.execute` 直接调用定义。 +挂载代码通过三道控制跨越 vm 边界。双 realm `instanceof` 同时识别宿主和 vm 对象。`harness.defineTool` 将结果规范化为宿主 realm 的 JSON,并在记录日志前校验 `ToolExecuteReturn` 形状。挂载的插件接收的是一个白名单上下文门面,而非原始或透传的 `Context`;框架管道和以 context 为值的返回会被拒绝。服务读取需要声明 `inject`,保留 Cordis 的激活与卸载语义。`ctx.tools.get` 仅暴露 schema 视图,因此挂载代码无法绕过 `ToolRegistry.execute` 直接调用定义。 -边界将无歧义的 JSON-Schema 形式规范化为 `SchemaSpec`,包括对象包装、`integer` 和可选字段。无效词汇会失败并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 错误和重复工具错误会包含相关源代码行或纠正性契约,但不叙述实现内部细节。 +边界将无歧义的 JSON-Schema 形式规范化为 `SchemaSpec`,包括对象包装器、`integer` 和可选字段。无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性契约,不叙述实现内部细节。 ### 动态分组与挂载生命周期 -所有动态挂载都是工具插件下方一个 `cordis-dynamic` 分组的子节点,因此普通的 fiber disposal 即可处理重载和卸载。挂载会等待 settlement;启动失败会在返回错误前 dispose 该 fiber。已 settle 但处于 pending 状态的挂载仍然可见,并列出其缺失的注入。`cordis_unmount` 等待挂载 fiber 的 disposal。 +所有动态挂载都是工具插件下方 `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 注意事项:挂载提供的服务值是 vm realm 对象——从任何地方调用其方法都能工作,但消费方不得假设其上有宿主原型。 +挂载之间通过普通的 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 扫描,输出服务摘要、签名、事件模式、引用的类型声明和继承的上下文接口面。有歧义的类型名被省略,过大的声明被标记为截断。 +`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、事件模式、引用的类型声明以及继承的 context 接口面。有歧义的类型名被省略,过大的声明被标记为截断。 -新鲜度像所有生成产物一样受门禁保护:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此修改了公开签名的 JSDoc 变更在不重新生成模型所读目录的情况下无法发布。运行时,inspect 工具将目录与活跃运行时取交集而非直接转储:有目录条目的活跃服务渲染摘要 + 签名,没有目录条目的活跃服务(挂载提供的)渲染名称 + 所属 fiber,有目录条目但没有活跃提供方的服务简要列出,引用的类型形状随后附上。 +新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此修改了公开签名的 JSDoc 变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:有目录条目的活跃服务渲染摘要 + 签名,没有目录条目的活跃服务(挂载提供的)渲染名称 + 所属 fiber,有目录条目但无活跃提供方的服务简要列出,引用的类型形状随后附上。 ### 配置、渲染与可观测性 -该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名称、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。 +该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。 -「模型可见 ⟺ 已记录」成立,且不引入新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它),而挂载引起的工具集变化则由循环在 schema 在步骤间变化时已有的请求头 delta 日志记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复持久化的会话会重建对话但不会重新挂载插件。 +「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它们),而挂载引起的工具集变化由循环在 schema 在步骤间发生变化时已有的 request-header delta 记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复一个持久化的会话会重建对话,但不会重新挂载插件。 ## 曾考虑的替代方案 -**用结构化的逐能力注册工具替代 `cordis_mount`。** 最诱人的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`、……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景省去插件样板——不足以抵偿其代价,而单一的挂载原语能一次性覆盖所有能力。 +**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。 | 维度 | 结构化逐能力工具 | 单一 `cordis_mount` | |---|---|---| -| Schema 正确性 | `parameters` 仍是模型编写的 JSON 对象,需要 SchemaSpec 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误 | -| 代码字段 | `execute` 体仍是 vm 中模型编写的 JS;realm 和服务调用正确性问题不变 | 一个沙箱、一条规范化路径、一道受守护的注册 | -| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇(一个 Cordis 插件)覆盖当前和未来的所有效果 | -| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通 Cordis 语义 | -| 可审视性 | 注册的东西在插件列表中无法作为插件展示 | 模型挂载的东西正是 `cordis_inspect` 渲染的东西 | -| 模型易用性 | 对最常见的单一场景有优势(无插件样板) | 通过挂载描述中的规范示例加上教导正确做法的边界错误来缓解 | +| Schema 正确性 | `parameters` 仍然是模型编写的 JSON 对象,需要 SchemaSpec 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 | +| 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JS;realm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 | +| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇(cordis 插件)覆盖当前和未来的所有效果 | +| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通的 cordis 语义 | +| 可审视性 | 注册的东西无法在插件列表中显示为插件 | 模型挂载的正是 `cordis_inspect` 渲染的 | +| 模型人机工程学 | 对最常见的单一场景有优势(无插件样板) | 通过 mount 描述中的规范示例加边界错误信息教会正确调用来缓解 | -因此,正确性投入放在能一次性覆盖所有能力的地方:通过 `cordis_inspect` 暴露的生成 API 目录,以及沙箱边界校验——其错误消息教导正确的调用方式。结构化注册工具日后仍可作为语法糖添加,合成挂载代码即可;本设计不排斥它。 +因此正确性投入放在能一次性为所有能力带来回报的地方:通过 `cordis_inspect` 呈现的生成 API 目录,以及沙箱边界校验(其错误信息教会正确的调用方式)。结构化注册工具日后仍可作为语法糖添加,由它合成 mount 代码;本设计不排斥这一可能。 -**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一张手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节,且没有门禁检测这种漂移;而生成产物的新鲜度由与文档使用同一 AST 的检查来保证。 +**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一份手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节且没有门禁约束这种漂移,而生成产物的新鲜度由文档使用的同一套 AST 检查。 -**新增 `cordis/mount` 会话事件。** 记录每次挂载(源码、名称)的持久溯源事件有明确先例(`hook/invoked`、`compact/start`)。v1 中否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为请求头 delta 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源与工具调用分离,日后仍可添加。 +**新增 `cordis/mount` 会话事件。** 一个持久的溯源事件记录每次挂载(源码、名称)有明确先例(`hook/invoked`、`compact/start`)。v1 中予以否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为 request-header delta 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源从工具调用中分离出来,日后仍可添加。 -**加固的 / 能力受限的沙箱。** 拦截 Node 内置模块并向挂载代码提供白名单门面而非原始 context,可能暗示意图是为安全而沙箱化。明确声明并非如此:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 Cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未守护的 context 逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)对一个开发/主动启用的工具集来说超出范围,且与其核心目标——将活跃运行时交给模型——相悖。 +**加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始 context,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的 context 逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。 ## 后果 -该工具集是刻意需要主动启用的,具有完全权限的 `ctx`,因此部署方采用它的意识程度与采用 bash 工具相同。以下事实由工具描述直接告知模型:waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。 +该工具集是刻意的显式启用设计,具有完全特权的 `ctx`,因此部署方采用它的意识程度应与 bash 工具相当。以下几个事实由工具描述直接告知模型:一个 waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此一个挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会导致死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。 diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml b/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml index eef8b3d86c..c824942f12 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml @@ -3,4 +3,4 @@ # 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: 8b742ac19fea21d8404f5f44aa64f8c3cb3efccc -2026-07-10-session-query-service.zh.md: 43175b05d82ad758a16e516f3fd8b7b650f9d762 +2026-07-10-session-query-service.zh.md: 73f47d0cc0306b3dcb6552c686f8f1a71ffbdc87 diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md index 43175b05d8..73f47d0cc0 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md @@ -1,43 +1,43 @@ # RFC:精确会话查询服务 -Status: implemented - [English](2026-07-10-session-query-service.md) | 中文 +Status: implemented + ## 问题 -会话历史存在于两处:当前的 `SessionStore` 对象和可选的持久化后端。需要精确检查的消费方如果不借助统一服务,就得各自重复实现活跃/持久化优先级、持久化生命周期处理、原始事件 surface 分类和防御性克隆。检查点之间持久状态可能落后于活跃日志,因此单靠持久化并不是可信的当前数据源。 +会话历史存在于两处:当前的 `SessionStore` 对象与可选的持久化后端。需要精确检查的消费方若无统一服务,就不得不各自重复实现活跃/持久化优先级判定、持久化生命周期处理、原始事件的 surface 分类以及防御性克隆。在检查点之间,持久化状态可能落后于活跃日志,因此仅靠持久化并非当前状态的可靠来源。 -全文搜索与此相关但规模大得多。在真正的后端出现之前就设计提供方注册、抽取、同步、失效、排序和游标契约,会产生两个投机性的状态机:一个在接口服务中,另一个在最终的数据库包中。 +全文搜索与此相关,但规模大得多。在真实后端尚不存在时就设计提供方注册、提取、同步、失效、排序和游标契约,会产生两个投机性的状态机:一个在接口服务中,另一个在最终的数据库包(package)中。 ## 决策 -`@deepseek-ai/dsh-session-query` 拥有 `ctx.sessionQuery`:一个面向单一逻辑语料库的小型可信精确读取服务。它暴露 `listSessions()`、`listEvents(sessionId)` 和有界的 `readEvent(request)`。它不暴露过滤器、血缘/溯源遍历、文本抽取器、搜索请求、提供方注册或派生索引同步。 +`@deepseek-ai/dsh-session-query` 拥有 `ctx.sessionQuery`,这是一个小型的、受信任的精确读取服务,面向单一逻辑语料库。它暴露 `listSessions()`、`listEvents(sessionId)` 和有界的 `readEvent(request)`。它不暴露过滤器、血缘或溯源遍历、文本提取器、搜索请求、提供方注册或派生索引同步。 -该服务动态观察可选的 `ctx.sessionPersistence` 绑定,但不保留持久化缓存或失效监听器。每次跨语料库列举都向活跃后端请求权威元数据,然后叠加一份新鲜的活跃 store 列表。id 匹配的条目合并为一条 `SessionRecord`:活跃 header 优先,`live`/`persisted` 独立报告来源可用性。不可变 header 不一致时报 `SESSION_QUERY_SOURCE_CONFLICT`。 +该服务动态观察可选的 `ctx.sessionPersistence` 绑定,但不保留持久化缓存或失效监听器。每次跨语料库列表操作向活跃后端请求权威元数据,然后叠加一份新鲜的活跃 store 列表。id 匹配的条目合并为一条 `SessionRecord`:活跃 header 优先,`live`/`persisted` 各自独立报告来源可用性。不可变 header 不一致时产生 `SESSION_QUERY_SOURCE_CONFLICT`。 -精确目标读取首先检查活跃 store,快照活跃 header 和事件日志。此路径从不查询持久化,因此持久化后端故障不会使已知的活跃历史变得不可读。当活跃 store 中无目标时,服务列举当前持久化元数据、证明该 id 存在、加载它,并在列举/加载的 header 不一致时拒绝。所有返回的 header 和事件都经过一次 structured-clone 边界。 +精确目标读取首先检查活跃 store,快照活跃 header 与事件日志。此路径从不查询持久化,因此持久化后端故障不会导致已知的活跃历史不可读。若活跃 store 中无目标,服务列出当前持久化元数据、证明该 id 存在、加载它,并在列表/加载 header 不一致时拒绝。所有返回的 header 与事件都经过一次 structured-clone 边界。 ## Surface 语义 -`dsh-session` 导出 `foldSurface(events)`,`SurfaceManager` 对其增量缓存使用相同的转换函数。fold 返回分离的当前节点以及每次替换实际移除的 seq。`listEvents()` 利用该结果将每个原始事件分类为 `current`、`shadowed` 或 `log-only`,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。 +`dsh-session` 导出 `foldSurface(events)`,`SurfaceManager` 使用相同的转换函数维护其增量缓存。fold 返回分离的当前节点以及每次替换实际移除的 seq。`listEvents()` 利用该结果将每个原始事件分类为 `current`、`shadowed` 或 `log-only`,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。 -`readEvent()` 返回完整的目标事件以及按连续 seq 排列的原始邻居。`before` 和 `after` 默认为零,各自受 `readWindowMax`(默认 50)约束。结果携带克隆的 `SessionHeader` 而非来源可用性记录,因为判断活跃目标的 persisted 标志会违反「活跃精确读取不依赖持久化健康状态」这一保证。 +`readEvent()` 返回完整的目标加上按连续 seq 排列的原始相邻事件。`before` 和 `after` 默认为零,各自受 `readWindowMax`(默认 50)约束。结果携带克隆的 `SessionHeader` 而非来源可用性记录,因为判断活跃目标的 persisted 标志会违反「活跃精确读取不依赖持久化健康状态」这一保证。 ## 安全边界 -该服务是上下文范围内的可信基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话作用域。本阶段不添加面向模型的工具,也不改变 transcript(文本记录)或快照 surface。 +该服务是上下文级别的受信任基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话范围。本阶段不添加面向模型的工具,也不改变 transcript(文本记录)或快照的 surface。 ## 曾考虑的替代方案 -- **让每个消费方自行实现逻辑语料库解析**:否决。来源优先级、冲突处理、可选服务生命周期、克隆和 surface 分类是共享的正确性规则。 -- **只查询持久化**:否决。检查点之间持久化可能落后于当前活跃日志。 -- **缓存持久化元数据并监听写入/删除**:否决。精确读取可以直接询问权威来源,而缓存失效在规模尚未要求之前就引入了生命周期和并发状态。 +- **将逻辑语料库解析直接放在每个消费方中**:否决。来源优先级、冲突处理、可选服务生命周期、克隆与 surface 分类是共享的正确性规则。 +- **仅查询持久化**:否决。检查点可能落后于当前活跃日志。 +- **缓存持久化元数据并监听写入/删除**:否决。精确读取可以直接询问权威来源,而缓存失效在规模尚未要求时就引入了生命周期与并发状态。 - **现在就定义提供方无关的搜索协议**:否决。目前没有提供方消费它。第一个 SQLite FTS 包应自行拥有一个协调/事务状态机;只有当第二个实现证明了边界时,才提取更小的共享 seam。 -- **在第一阶段就包含血缘、溯源和通用过滤器**:否决。当前没有消费方需要它们,且规范日志足以在有证据时再行添加。 +- **在第一阶段就包含血缘、溯源和通用过滤器**:否决。当前没有消费方需要它们,且规范日志足以在日后有证据时再行添加。 ## 后果 -第一阶段只有一个来源解析状态变量:当前挂载的持久化服务。没有提供方队列、指纹、抽取器注册表、观察代次或派生索引更新。精确读取在纯活跃部署中仍然可用,在持久化存在时具有确定性。 +第一阶段只有一个来源解析状态变量:当前挂载的持久化服务。没有提供方队列、指纹、提取器注册表、观察代次或派生索引更新。精确读取在纯活跃部署中仍然可用,在持久化存在时具有确定性。 -跨语料库列举和持久化精确读取每次调用都执行后端 I/O。这是有意为之:正确性来自当前权威状态,面向规模的搜索属于第二阶段的数据库。全文搜索在该包定义并实现其完整契约之前不可用。 +跨语料库列表与持久化精确读取在每次调用时执行后端 I/O。这是有意为之:正确性来自当前权威状态,面向规模的搜索属于第二阶段的数据库。在该包定义并实现其完整契约之前,全文搜索不可用。 diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index ad328eaaaa..6675490bb9 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -3,4 +3,4 @@ # 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: 368f3a3592c5e241bb9357d4d4ce32e175c3de45 -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6c1ce8ac08fe3d37c400d489808e592570ebd6c7 +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: cc78a472df014ce1eb9114277e0520c7e9c051bb diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index 6c1ce8ac08..cc78a472df 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -1,94 +1,94 @@ -# RFC:配置 subagent 的 persona、工具可见性与深度 - -Status: implemented +# RFC:配置 subagent 的人设、工具可见性与深度 [English](2026-07-12-subagent-persona-tool-filter-and-depth.md) | 中文 +Status: implemented + ## 问题 -一个可复用的 subagent 提供方解决的是「如何运行子 agent」的问题,但不同的委派工具需要不同的子 agent 行为。某个部署可能需要一个评审者 persona、一组仅限研究的工具集,或一个硬性递归上限,而不必为每种组合都创建新的提供方。 +一个可复用的 subagent 提供方解决的是「如何运行子 agent(智能体)」的问题,但不同的委派工具需要不同的子 agent 行为。某个部署可能需要评审者人设、仅限研究的工具集,或硬性递归上限,而不必为每种组合创建新的提供方。 -这些控制影响子 agent 的第一次模型请求,因此不能在子 agent 可见之后才安装。它们还需要提供方诚实地声明支持:ACP 后端不能静默接受一个仅适用于进程内的工具过滤器,而过滤器也不应在所有插件运行于同一可信进程时被描述为安全边界。 +这些控制影响子 agent 的第一次模型请求,因此不能在子 agent 可见之后再安装。它们还需要提供方的诚实支持:ACP(Agent Client Protocol)后端不能默默接受一个仅限进程内的工具过滤器,而过滤器在所有插件运行于同一可信进程的情况下也不应被描述为安全边界。 ## 决策 -subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持,服务在启动运行前拒绝不支持的请求,而进程内提供方在子 agent 尚未发布时安装所请求的组合。 +subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持情况,服务在启动运行之前拒绝不受支持的请求,进程内提供方在子 agent 尚未发布时安装所请求的组合。 这些控制回答不同的问题: | 控制 | 问题 | 结果 | |---|---|---| -| `persona` | 哪些角色指令替换该子 agent 的部署 persona? | 一个子 agent 局部的 prompt 段落遮蔽 `deployment:persona` | -| `toolFilter` | 哪些部署全局工具进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 | -| `maxDepth` | 这棵委派树最深可以长到多少层? | 当子 agent 深度超过绝对上限时,启动请求被拒绝 | +| `persona` | 什么角色指令替换该子 agent 的部署人设? | 一个子 agent 局部的 prompt 段落遮蔽 `deployment:persona` | +| `toolFilter` | 部署全局工具中哪些进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 | +| `maxDepth` | 这棵委派树最深可以长到多少层? | 子 agent 深度超过绝对上限时,启动请求被拒绝 | -`dsh-tool-subagent` 将这些控制作为插件配置暴露,并将它们复制到每个创建的请求中。直接调用 `SubagentService` 的调用方可以按请求选择。提供方能力描述符仍然是后端能否兑现各字段的真源。 +`dsh-tool-subagent` 将这些控制作为插件配置暴露,并复制到它创建的每个请求中。直接调用 `SubagentService` 的调用方可以按请求选择这些控制。提供方的能力描述符仍然是后端能否兑现各字段的真源。 -### Persona 是有作用域的遮蔽 +### 人设是有作用域的遮蔽 -persona 控制改变一个子 agent 而不改变部署级别的 prompt 组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者胜出解析规则仅在该子 agent 的组装中替换全局段落。 +人设控制改变一个子 agent 的行为,而不改变部署级的 prompt 组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。 -其值具有与部署 persona 相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局 persona。父 agent 和兄弟 agent 的 persona 永远不会进入子 agent 的扁平作用域。 +其值与部署人设具有相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局人设。父级和兄弟级的人设永远不会进入子 agent 的扁平作用域。 -这使用的是正常的系统提示词注册机制,而非第二条 persona 通道。因此第一次 prompt 看到的命名贡献与后续 prompt 和 prompt 检查工具看到的相同。 +这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次 prompt 看到的命名贡献与后续 prompt 和 prompt 检查工具看到的一致。 -### 工具过滤是一条实时的全局视图规则 +### 工具过滤是一条作用于全局视图的活规则 -工具过滤器同时控制能力可见性与可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRegistry.restrict()`,注册表的单一解析器将相同结果应用于协议格式(wire format)的工具 schema、查找、执行和 Code Mode SDK 生成。独立注册的系统提示词段落不在 `ToolRegistry` 内,因此过滤一个工具不会移除该插件的独立指导文本。 +工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRegistry.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 Code Mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRegistry` 内,因此过滤一个工具不会移除该插件的独立指导文本。 解析遵循以下规则: -1. 每个限制对实时的部署全局工具注册表先应用 `allow` 再应用 `deny`。 -2. 多个限制取交集,因此每个已安装的限制都必须放行一个全局工具。 +1. 每条限制对活跃的部署全局工具注册表先应用 `allow` 再应用 `deny`。 +2. 多条限制取交集,因此每条已安装的限制都必须放行一个全局工具。 3. 子 agent 作用域的工具在全局过滤之后添加,可以遮蔽一个已放行的全局工具。 -4. 保留的 `run_code` 呈现和其他作用域局部的协议贡献不在全局过滤器范围内。 +4. 保留的 `run_code` 呈现和其他作用域局部的协议贡献不受全局过滤器影响。 -当过滤器既不提供 `allow` 也不提供 `deny`,或命名了当前全局可限制集合之外的内容(包括仅作用域局部或保留的名称)时,配置会大声失败。`allow: []` 是合法的,它有意隐藏所有全局工具。这些检查能捕获拼写错误,并防止配置在无法影响所命名条目时看起来有效。 +当过滤器既未提供 `allow` 也未提供 `deny`,或命名了当前全局可限制集合之外的内容(包括仅作用域局部或保留名称)时,配置会显式失败。`allow: []` 合法,且有意隐藏所有全局工具。这些检查能捕获拼写错误,并防止配置在无法影响所命名条目时看起来仍然有效。 -全局注册表保持实时。仅 deny 的过滤器会放行后续注册的全局名称(除非显式 deny 该名称);allow 列表会排除后续注册的全局名称(除非显式 allow 该名称)。移除一个全局工具会将其从所有解析视图中移除。这些语义在保持热注册的同时,使 allow 与 deny 的区别显式化。 +全局注册表保持活跃。仅 deny 的过滤器会放行后来注册的全局名称(除非显式 deny 该名称);allow 列表会排除后来注册的全局名称(除非显式 allow 该名称)。移除一个全局工具会将其从所有已解析视图中移除。这些语义在保持热注册的同时,使 allow 与 deny 的区别显式化。 ### 深度是绝对的树上限 -深度限制独立于工具可见性来约束递归委派。顶层 agent 的深度为零;进程内子 agent 的深度为其父 agent 经验证的深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。 +深度限制独立于工具可见性来约束递归委派。顶层 agent 深度为零;进程内子 agent 的深度为其父级已验证深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。 -每个公开入口都验证值域,而不依赖单一的面向模型的配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的已存储父深度以及推导溢出都会被拒绝。省略上限则该机制不约束深度。 +每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。省略上限时,此机制不约束深度。 -部署可以组合深度与过滤。例如,可以在深度一时保留委派工具可见但设置 `maxDepth: 1`,或在子 agent 中完全 deny 委派工具。两种选择都不改变提供方的对话历史行为。 +部署可以组合深度与过滤。例如,可以在深度一时保持委派工具可见但设置 `maxDepth: 1`,或在子 agent 中完全 deny 委派工具。两种选择都不改变提供方的对话历史行为。 ### 能力门控保持提供方诚实 -能力将请求的特性与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中的每个字段。 +能力将请求的特性与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。 -这使得 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择一个不支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。 +这使 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择不受支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。 -### 未发布的设置使第一次请求正确 +### 未发布设置使第一次请求正确 -所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装 persona、工具限制和结构化输出贡献。只有设置成功后,创建才会发布会话和 agent 并允许驱动器启动。 +所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装人设、工具限制和结构化输出贡献。只有设置成功后,创建才发布会话和 agent 并允许驱动器启动。 -设置失败会回滚私有的子 agent。没有观察者能获取到一个「第一次 prompt 使用了部署 persona 或未过滤工具集、后续 prompt 才使用请求配置」的子 agent。 +设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次 prompt 使用了部署人设或未过滤工具集、后续 prompt 才使用所请求配置」的子 agent。 ## 可见性不是授权 -这些控制组合的是可信的同进程行为;它们不授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 是父 agent 的子集,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。 +这些控制组合的是同一可信进程内的行为,而非授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 是其父级的子集,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。 -特别地,子 agent 局部工具在全局过滤之后添加,可能不在父 agent 的视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后续全局工具。这些是有意的实时组合语义,而非不可提权保证。 +具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的活组合语义,而非不可升级保证。 -安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父集合子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签都不在本特性范围内。 +安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签均不在本特性范围内。 ## 曾考虑的替代方案 -**为每个 persona 或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍然需要递归机制。提供方的职责仍然是执行传输;请求承载每个子 agent 的组合。 +**为每种人设或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍需要递归机制。提供方的职责是执行传输;请求承载每个子 agent 的组合。 -**复制父 agent 的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析的视图还会冻结动态全局注册,并在未完整定义任一契约的情况下混淆组合与授权。 +**复制父级的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析视图还会冻结动态全局注册,并在未完整定义任一契约的情况下混淆组合与授权。 -**在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册一律不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为实时注册表谓词,并直接记录 allow 与 deny 的行为。 +**在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册统一不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为活跃的注册表谓词,并直接记录 allow 与 deny 的行为。 -**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个 prompt 声称不存在的工具。改为由一个解析器同时管控呈现与执行。 +**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个 prompt 声称不存在的工具。改为由一个解析器同时管控呈现和执行。 -**仅用工具过滤来阻止递归。** 移除委派工具有用但依赖特定提供方,且无法保护直接的服务调用方或替代委派工具。绝对深度是一个独立的结构性约束。 +**仅用工具过滤来阻止递归。** 移除委派工具有用但依赖特定提供方,且不保护直接服务调用方或替代委派工具。绝对深度是独立的结构性约束。 ## 后果 -贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始前失败,未发布的设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 +贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 -代价是部署方必须理解实时 allow/deny 行为以及可见性与授权的区别。提供方作者必须准确声明每个支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可提权问题。 +代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升级问题。 diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml index 31c5a1b2e0..120abae517 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-doc-sync-enforcement.md: 44ea84daddcae73ce07b0a8240f83ee9945e449d -2026-06-11-doc-sync-enforcement.zh.md: e7abe2d87fd97211f653b63ddb8818077532af48 +2026-06-11-doc-sync-enforcement.zh.md: c739e9661bb926c772f1d5399529a813ac59991c diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md index e7abe2d87f..c739e9661b 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md @@ -1,32 +1,32 @@ # RFC:Doc-sync 强制 -Status: implemented - [English](2026-06-11-doc-sync-enforcement.md) | 中文 +Status: implemented + ## 问题 -AGENTS.md 承诺文档与代码严格同步,但这一承诺此前只靠肉眼验证。评审曾两次发现漂移:一次是实操手册(cookbook)示例与类型策略矛盾,一次是 README 引用了错误的 `registerAdapter` 调用。失去同步的文档比没有文档更糟;而本代码库主要由 agent 构建,agent 对门禁的遵从远比对行文的遵从可靠(机械质量门禁)。有两类文档漂移可以被机械检查:不再能编译的代码块,以及重复了 `interface Events` 声明的事件分类体系表。 +AGENTS.md 承诺文档与代码严格同步,但这一承诺此前仅靠人眼核查。评审曾两次发现漂移:一次是实操手册(cookbook)示例与类型策略矛盾,一次是 README 引用了错误的 `registerAdapter` 调用。失去同步的文档比没有文档更糟;而本代码库主要由 agent(智能体)构建,agent 遵守门禁远比遵守行文约定可靠(机械质量门禁)。有两类文档漂移可以被机械检查:不再能编译的代码块,以及与 `interface Events` 声明重复的事件分类体系表。 ## 决策 两道门禁,沿用既有的 `scripts/` 风格(tsx ESM,每个脚本一项职责): -1. **`doc-typecheck`** 从 `README.md`、`docs/**` 和 `packages/*/README.md` 中提取所有 ` ```ts ` 围栏代码块,写入一个继承根 `tsconfig.json` 的临时项目,然后用 `tsc -b` 编译。临时项目复用源码的 `paths` 映射和根 project references,因此文档示例能看到源码,而 vendor 代码仍在其自身的 tsconfig 设置下被检查。刻意作为草图的代码块可以用显式的 ` ```ts ignore-check ` 信息字符串退出检查;脚本会报告退出比例,超过一半则失败,防止逃生口悄悄变成常态。 -2. **`verify-event-taxonomy`** 从 `packages/*/src` 的 `interface Events` 块中提取事件名,再从 `docs/architecture.md` 的分类体系表中提取事件名,断言两个集合完全一致。只校验、不生成:表格保留手写的 Mode/Purpose 列,只检查名称集合。(落地此门禁时发现了表格缺失的三个事件:`tools/change`、`llm/adapter-change`、`system-prompt/change`。)**已被取代**:[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)取代了此门禁及其 `architecture.md` 表格,改为完全生成的 `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` 及其 `verify-cordis-catalog` 新鲜度门禁。本文的其他门禁(`doc-typecheck` 以及下文修订的 `verify-md-wrap`)不受影响。 +1. **`doc-typecheck`** 从 `README.md`、`docs/**` 和 `packages/*/README.md` 中提取所有 ` ```ts ` 围栏代码块,写入一个继承根 `tsconfig.json` 的临时项目,然后用 `tsc -b` 编译。临时项目复用源码的 `paths` 映射和根 project references,因此文档示例能看到源码,而 vendor 代码仍在其自身的 tsconfig 设置下被检查。刻意作为草图的代码块可通过显式的 ` ```ts ignore-check ` 信息字符串来 opt-out;脚本会报告 opt-out 比例,超过一半即失败,防止该豁免机制悄然成为常态。 +2. **`verify-event-taxonomy`** 从 `packages/*/src` 中的 `interface Events` 块和 `docs/architecture.md` 中的分类体系表分别提取事件名称,断言两个集合完全一致。只校验,不生成:表格保留手写的 Mode/Purpose 列,仅检查名称集合。(落地此门禁时发现了表格遗漏的三个事件:`tools/change`、`llm/adapter-change`、`system-prompt/change`。)**已被取代**:由[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)取代。此门禁及其 `architecture.md` 表格已退役,取而代之的是完全生成的 `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` 及其 `verify-cordis-catalog` 新鲜度门禁。本 RFC 中的其他门禁(`doc-typecheck` 以及下文修订中的 `verify-md-wrap`)不受影响。 -两者通过一个共享的 `doc-sync` package.json 脚本运行,lefthook pre-push 钩子和 CI 都调用它([机械质量门禁](2026-06-11-quality-gates.md):钩子和 CI 调用相同的脚本,因此门禁在推送前就在本地触发,而不仅仅在推送后)。它们在 `pnpm run typecheck` 之后运行,后者校验 doc-typecheck 所引用的 package/vendor 构建图。 +两者通过一个共享的 doc-sync(文档同步门禁)`package.json` 脚本运行,lefthook pre-push 钩子和 CI 都调用它([机械质量门禁](2026-06-11-quality-gates.md):钩子与 CI 调用相同脚本,因此门禁在推送前就在本地触发,而非仅在推送后)。它们在 `pnpm run typecheck` 之后运行,后者校验 doc-typecheck 所引用的 package/vendor 构建图。 -**修订(2026-06-17):** 第三道门禁 **`verify-md-wrap`** 后来也被纳入 `doc-sync`。它用 `mdast-util-from-markdown` + GFM 解析范围内的每个 Markdown 文件(`README.md`、`docs/**`、`packages/*/README.md`,加上 `AGENTS.md` / `packages/AGENTS.md`),对任何跨越多行的 `paragraph` 节点报错,强制执行 docs/AGENTS.md 中「一个段落一个物理行」的写作规则。同样遵循只校验不生成的原则:它报告硬换行,从不重写,因此不会引入格式化噪音。`doc-sync` 现在包含三道门禁。 +**修订(2026-06-17):** 第三道门禁 **`verify-md-wrap`** 随后被纳入 `doc-sync`。它使用 `mdast-util-from-markdown` + GFM 解析范围内的每个 Markdown 文件(`README.md`、`docs/**`、`packages/*/README.md`,加上 `AGENTS.md` / `packages/AGENTS.md`),如果任何 `paragraph` 节点跨越多个源码行则失败,从而强制执行 docs/AGENTS.md 中「一个段落一个物理行」的写作规则。同样遵循只校验不生成的原则:它报告硬换行但从不重写,因此不会引入格式化噪音。`doc-sync` 现在包含三道门禁。 ## 曾考虑的替代方案 -- **API-extractor 黄金报告**([已推迟的提案](../../proposed/process/2026-06-11-api-extractor-reports.md)):有意推迟。对于评审者已经能看到源码 diff 的内部 monorepo 而言价值不高,且依赖笨重、配置繁琐。 -- **从源码生成分类体系表**而非校验名称:否决,机制比问题本身更重;表格保留手写的 Mode/Purpose 列,直到[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)完全取代了这项检查。 +- **API-extractor 金标报告**([已推迟的提案](../../proposed/process/2026-06-11-api-extractor-reports.md)):有意推迟。对于评审者已能直接看到源码 diff 的内部 monorepo 而言价值有限,且依赖重、配置繁琐。 +- **从源码生成分类体系表**而非仅校验名称:否决,机制比问题本身更重;表格保留了手写的 Mode/Purpose 列,直到[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)完全取代了这项检查。 ## 后果 -- 可机械检查的文档漂移现在会让 pre-push 钩子和 CI 失败,而非等待评审者发现。这是「机械门禁优于行文约定」原则的一个实例。 -- 让文档代码片段可编译需要少量 stub import 或 `declare`;`ignore-check` 比例必须保持低位,否则门禁形同虚设(比例守卫强制执行这一点)。 -- 分类体系检查仅限名称:Mode 或 Purpose 列的错误仍需人工评审。 -- 如果这些包(package)将来对外发布,API 报告仍可重新考虑。 +- 可检查类别的文档漂移现在会让 pre-push 钩子和 CI 失败,而非等待评审者发现。这是「机械门禁优于行文约定」原则的一个实例。 +- 让文档代码片段可编译需要少量 stub import/`declare`;`ignore-check` 比例必须保持低位,否则门禁形同虚设(比例守卫强制执行此约束)。 +- 分类体系检查仅限名称——Mode 或 Purpose 列的错误仍需人工评审。 +- 如果 package 未来对外发布,API 报告方案仍可重新考虑。 diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml index addb813047..ba5bb828a2 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-quality-gates.md: 9862dc019dd6ff3b4639983821256395d0ee7b77 -2026-06-11-quality-gates.zh.md: 7a9dd7cead0e7a4964a44b650664ceb7ff570c7b +2026-06-11-quality-gates.zh.md: a9c17bb700db091d21f7930942a8f3bbf55958a0 diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md index 7a9dd7cead..a9c17bb700 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md @@ -1,28 +1,28 @@ # RFC:以机械质量门禁取代行文约定 -Status: implemented - [English](2026-06-11-quality-gates.md) | 中文 +Status: implemented + ## 问题 -本代码库主要由 coding agent 开发。相比行文约定,agent 遵守强制门禁的可靠性远高得多;而当劳动由 agent 完成时,「工作量大」不构成成本论据。早期证据:未通过类型检查的测试被提交了(vitest 不做类型检查),只在评审时才被发现。 +本代码库主要由 coding agent(智能体)开发。相比行文约定,agent 遵守强制门禁的可靠性远高得多;而当劳动由 agent 承担时,「工作量大」不构成成本论据。早期证据:未通过类型检查的测试被提交(vitest 不做类型检查),仅在评审中才被发现。 ## 决策 -AGENTS.md 中的每一项承诺都对应一条退出码非零的命令,同时接入 git 钩子和 CI,两者调用相同的 package.json 脚本: +AGENTS.md 中的每一条承诺都对应一个以非零退出码表示失败的命令,通过 git 钩子和 CI 调用同一套 package.json 脚本来执行: -- 最严格的 TypeScript(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录 no-emit `tsconfig.json` 在 CI 中进行类型检查,而 package/vendor 代码保持在各自 project-reference 边界之后。 -- ESLint strict-type-checked + @stylistic(作为强制执行的项目风格),包括文件内重复逻辑检查;vendor 代码排除在外。 -- jscpd 检测 package 生产 TypeScript 和仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 -- `packages/*/*/src` 的逐文件 100% 覆盖率(v8);不可达的防御性守卫保留 `/* v8 ignore */ ` 并注明理由,而非删除。 -- knip(死代码/依赖)、publint(包正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 -- lefthook pre-commit(lint 暂存文件、类型检查、vendor manifest 守卫)和 pre-push(测试、hygiene);CI 在 Node 22.19/24/26 上运行完整矩阵,外加一个端到端驱动 echo-agent 的演示冒烟测试。 +- 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而 package/vendor 代码保持在各自 project-reference 边界之后。 +- ESLint strict-type-checked + @stylistic(作为强制执行的统一代码风格),包括文件内重复逻辑检查;vendor 代码排除在外。 +- jscpd 检测 package 生产 TypeScript 与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 +- `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */` 并注明理由,而非删除。 +- knip(死代码/依赖)、publint(包(package)正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 +- lefthook pre-commit(lint 暂存文件、类型检查、vendor manifest(元数据清单)守卫)和 pre-push(测试、hygiene);CI 在 Node 22.19/24/26 上运行完整矩阵,外加一个驱动 echo-agent 端到端的演示冒烟测试。 ## 后果 -- 约定在 agent 更替后仍然存续;违规在本地快速失败。 +- 约定在 agent 更替中得以存续;违规在本地快速失败。 - 门禁本身也是需要维护的代码;配置变更与其他变更一样需要评审。 -- 100% 覆盖率的压力可能催生无断言的测试——变异测试是计划中的对冲手段(见[变异测试提案](../../proposed/testing/2026-06-11-mutation-testing.md))。 +- 100% 覆盖率的压力可能催生无断言的测试——变异测试是计划中的对策(见[变异测试提案](../../proposed/testing/2026-06-11-mutation-testing.md))。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml index d7062a1c20..92dd0f65b6 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-tsdown-over-dumble.md: c16ac691a9452d952303cf73b40693447d25015c -2026-06-11-tsdown-over-dumble.zh.md: 637a15a49bf22a0dd006039dd1ae2d0ea8120424 +2026-06-11-tsdown-over-dumble.zh.md: 5e9dc5242225e4420e1faa6ef19c8e8b9b3fdbcd diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md index 637a15a49b..5e9dc52422 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md @@ -1,30 +1,30 @@ -# RFC:用 tsdown 替代 dumble 进行 JS 打包 - -Status: implemented +# RFC:使用 tsdown 替代 dumble 进行 JS 打包 [English](2026-06-11-tsdown-over-dumble.md) | 中文 +Status: implemented + ## 问题 -初始构建使用 **dumble**——cordiverse 的零配置 esbuild 包装层,上游 Cordis 本身也用它构建——与 vendor 包的约定最大程度对齐(它读取每个 package.json,从 `exports` 字段推断入口和格式)。但 dumble 作为本仓库的承重工具是一个隐患:v0.2.x,每周约 530 次 npm 下载,实质上只有一位维护者,而且由于它没有 workspace 模式,我们不得不通过一个自定义编排脚本(`scripts/build.ts`)来调用它。 +最初的构建使用 **dumble**,即 cordiverse 的零配置 esbuild 包装层——上游 Cordis 自身也用它构建——与 vendor 包(package)的约定最大程度对齐(它读取每个 package.json 并从 `exports` 字段推断入口/格式)。但 dumble 作为本仓库的承重工具存在隐患:v0.2.x,每周约 530 次 npm 下载,实质上只有一位维护者,而且由于它没有 workspace 模式,我们不得不通过自定义编排脚本(`scripts/build.ts`)来调用它。 -目前构建产物只对 `pnpm run build` + publint 有意义(尚无包发布;开发/测试/演示通过 tsx 直接运行未打包的源码),因此切换成本现在最低,一旦包开始发布就只会更高。 +目前构建产物只在 `pnpm run build` + publint 中有意义(尚未发布任何包;开发/测试/演示通过 tsx 直接运行未打包的源码),因此切换成本现在最低,一旦包开始发布就只会更高。 ## 决策 用 **tsdown**(基于 rolldown,每周约 250 万次下载,VoidZero 支持,活跃发布)替代 dumble: -- 根目录 `tsdown.config.ts`,配置 `workspace: ['vendor/*', 'packages/*/*']`(显式 glob 将打包范围限定在 vendor Cordis 和 TypeScript 包树;`workspace: true` 还会发现示例 manifest 和不需要打包的 workspace 成员)。 -- 共享形态:入口 `lib/types/index.js`,`outDir: 'lib'`,ESM,`platform: node`,`target: es2024`,`fixedExtension: false`(对 `"type": "module"` 的包保持 `.js` 扩展名),`dts: false`(声明文件由 tsc -b 负责),`clean: false`(lib/ 同时存放 TSC 的 `lib/types` 中间产物树)。入口最初是 `src/index.ts`;[TSC 优先构建 RFC](2026-06-17-ts-build-config.md) 后来将 tsdown 改为打包 TSC 输出的 JS,使 TypeScript 转换行为来自同一个编译器。 -- vendor/ 中有两个逐包覆盖配置(属于我们的修改,与重新生成的 tsconfig 一样;记录在 vendor/README.md 中):schemastery(通过 `outExtensions` 输出双格式 `.mjs`/`.cjs`)、logger-console(两次单入口 pass,使共享基类内联到每个入口而非生成 hash 命名的 chunk,与上游发布形态一致)。 -- 删除 `scripts/build.ts`;`pnpm run build` = `tsc -b tsconfig.build.json && tsdown`。 +- 根目录 `tsdown.config.ts`,配置 `workspace: ['vendor/*', 'packages/*/*']`(显式 glob 将打包范围限定在 vendor 的 Cordis 与 TypeScript 包目录树内;`workspace: true` 还会发现示例 manifest 和不需要打包的 workspace 成员)。 +- 共享形态:入口 `lib/types/index.js`,`outDir: 'lib'`,ESM,`platform: node`,`target: es2024`,`fixedExtension: false`(为 `"type": "module"` 的包保持 `.js` 扩展名),`dts: false`(声明文件由 tsc -b 负责),`clean: false`(lib/ 同时存放 TSC 的 `lib/types` 中间产物树)。入口最初是 `src/index.ts`;[TSC 优先构建 RFC](2026-06-17-ts-build-config.md) 后来将 tsdown 改为打包 TSC 输出的 JS,使 TypeScript 转换行为统一来自一个编译器。 +- vendor/ 中有两个按包覆盖的配置(属于我们自己的修改,与重新生成的 tsconfig 类似;记录在 vendor/README.md 中):schemastery(通过 `outExtensions` 输出双格式 `.mjs`/`.cjs`)、logger-console(两次单入口 pass,使共享基类被内联到每个入口而非生成哈希命名的 chunk,与上游发布形态一致)。 +- `scripts/build.ts` 删除;`pnpm run build` = `tsc -b tsconfig.build.json && tsdown`。 ## 曾考虑的替代方案 -- **直接编写 esbuild 脚本**:最成熟的引擎,零包装层风险,但需要手动维护 tsdown workspace 模式自动提供的逐包规格表。 -- **pkgroll**:理念上最接近的直接替代品,但每周仅 78k 下载且基于 Rollup:维护前景严格弱于 tsdown。 -- **保留 dumble**:与上游完美对齐,但 bus factor 不可接受。 +- **直接编写 esbuild 脚本**:最成熟的引擎,零包装层风险,但需要手动维护 tsdown workspace 模式自动提供的按包规格表。 +- **pkgroll**:理念上最接近的直接替代品,但每周仅 78k 下载且基于 Rollup,维护前景严格弱于 tsdown。 +- **保留 dumble**:与上游完美对齐,但巴士因子不可接受。 ## 后果 -运行时打包产物仍遵循 dumble 时代的公开入口形态(`lib/index.js`,加上包特有的变体如 `schemastery` 的 `lib/index.mjs`/`lib/index.cjs` 和 `logger-console` 的 `lib/browser.js`);声明文件现在按 [TSC 优先构建 RFC](2026-06-17-ts-build-config.md) 放在 `lib/types` 下。外部依赖仍来自各包的 dependencies/peerDependencies。我们放弃了 dumble 的 exports 字段推断能力:入口形态非默认的新包需要一个逐包的 `tsdown.config.ts`,而不能仅靠 package.json 字段。未来选项:如果 `tsc -b` 成为瓶颈,tsdown 也可以接管声明文件打包(isolatedDeclarations);那将是一个新的 RFC。 +运行时打包产物仍遵循 dumble 时代的公开入口形态(`lib/index.js`,以及按包特定的变体,如 `schemastery` 的 `lib/index.mjs`/`lib/index.cjs` 和 `logger-console` 的 `lib/browser.js`);声明文件现在位于 `lib/types` 下,见 [TSC 优先构建 RFC](2026-06-17-ts-build-config.md)。外部依赖仍来自各包的 dependencies/peerDependencies。我们放弃了 dumble 的 exports 字段推断功能:新增的非默认形态的包需要编写按包的 `tsdown.config.ts`,而不能仅靠 package.json 字段。未来可选方向:如果 `tsc -b` 成为瓶颈,tsdown 还可以接管声明文件打包(isolatedDeclarations);那将是一个新的 RFC。 diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml index baaef0f47c..b71b7d025d 100644 --- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-vendor-cordis-as-source.md: 39506300dec73d0c9eb1b7b2246caa23f1b10f7f -2026-06-11-vendor-cordis-as-source.zh.md: 1c942291481f50f602d6733e3c25a892885d47fe +2026-06-11-vendor-cordis-as-source.zh.md: 0e794d97c4d535b74279bab11bda519e7da2e366 diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md index 1c94229148..0e794d97c4 100644 --- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md @@ -1,27 +1,27 @@ -# RFC:以源码形式收录 Cordis,而非 npm 依赖 - -Status: implemented +# RFC:将 Cordis 以源码形式收录,而非作为 npm 依赖 [English](2026-06-11-vendor-cordis-as-source.md) | 中文 +Status: implemented + ## 问题 -DeepSeek Harness SDK 基于 Cordis 框架构建。本仓库启动时,Cordis core 处于 4.0.0-rc.6(一个发布候选版本);harness 依赖框架内部实现(fiber 生命周期、effect dispose(资源释放)、waterfall(瀑布式事件)分发),这些行为的精确语义直接关系到 agent loop(智能体循环)的正确性保证。 +DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时,Cordis core 处于 4.0.0-rc.6(一个候选发布版本);harness 依赖框架内部实现(fiber 生命周期、dispose(资源释放)、waterfall(瀑布式事件)分发),其确切行为直接关系到 agent loop(智能体循环)的正确性保证。 ## 决策 -将所需的 Cordis 包(core、loader、include、group、timer、hmr、logger-console)及 cordiverse 基础库(cosmokit、schemastery)以源码形式扁平复制到 `vendor/`,保留其原始 npm 包名,使 workspace 解析透明。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍留在 npm。 +将所需的 Cordis 包(core、loader、include、group、timer、hmr、logger-console)与 cordiverse 基础库(cosmokit、schemastery)以源码形式复制到 `vendor/`,扁平化放置,保留其原始 npm 包名以实现透明的 workspace 解析。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取。 -`vendor/README.md` 是 manifest(元数据清单):记录每个包的上游仓库 + commit SHA,以及一份详尽的本地修改日志。pre-commit 守卫(`scripts/check-vendor-manifest.sh`)会拒绝未在同一次提交中更新 manifest 的 vendor 源码改动。 +`vendor/README.md` 是 manifest(元数据清单):记录每个包(package)的上游仓库 + commit SHA,以及一份详尽的本地修改日志。pre-commit 守卫(`scripts/check-vendor-manifest.sh`)会拒绝未在同一次提交中更新 manifest 的 vendor 源码变更。 ## 曾考虑的替代方案 -- **依赖 npm 包**:否决。core 处于发布候选阶段,且 harness 依赖框架内部实现(fiber 生命周期、effect dispose、waterfall 分发),agent loop 的正确性保证取决于这些行为的精确语义;上游 RC 版本升级可能在没有本地修复路径的情况下破坏它们。 -- **传递性地收录所有依赖**:否决。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍留在 npm;只有内部实现对我们有影响的框架层才被纳入自有管理。 +- **依赖 npm 包**:否决。core 处于候选发布阶段,harness 依赖框架内部实现(fiber 生命周期、dispose、waterfall 分发),agent loop 的正确性保证取决于这些行为的确切表现;上游 RC 版本升级可能在没有本地修复路径的情况下破坏它们。 +- **递归收录所有传递依赖**:否决。真正的第三方依赖(js-yaml、chokidar、@standard-schema/spec 等)仍从 npm 获取;只有内部实现对我们有影响的框架层才需要自行持有。 ## 后果 -- harness 完全拥有其框架层:可审计、可打补丁、版本锁定。上游 RC 无法破坏我们,框架 bug 可以在仓库内直接修复。 -- 上游同步是手动的(manifest 中记录了操作步骤)。修改日志使 diff 面始终可知。 -- vendor 包保留上游代码风格;lint 与严格性门禁将其排除(它们的 tsconfig 在本地放宽了我们较新的编译器 flag)。 -- 从第一天起就存在一个本地补丁:移除了 HMR 的 locale-YAML 导入(运行时 YAML 导入钩子未被收录)。 +- harness 完全持有其框架层:可审计、可打补丁、版本锁定。上游 RC 无法影响我们,框架 bug 可以在仓库内直接修复。 +- 上游同步是手动操作(流程记录在 manifest 中)。修改日志使 diff 范围始终可知。 +- 收录的包保留上游代码风格;lint 与严格性门禁将其排除(它们的 tsconfig 在本地放宽了我们较新的编译器选项)。 +- 从第一天起就有一个本地补丁:移除了 hmr 的 locale-YAML 导入(运行时 YAML 导入钩子未被收录)。 diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index c2b598e0db..ec328ff0e0 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-16-pnpm-over-yarn.md: 6e7a6e1f53056e36f54f44b87b305afa593da549 -2026-06-16-pnpm-over-yarn.zh.md: 809f10dbd63d347eccb4d00641a39da51788ee9f +2026-06-16-pnpm-over-yarn.zh.md: ba63575909f2bafbbad2102c85d6bede77999997 diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index 809f10dbd6..ba63575909 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -1,43 +1,43 @@ -# RFC:以 pnpm 替代 Yarn 4 作为包管理器 - -Status: implemented +# RFC:使用 pnpm 替代 Yarn 4 作为包管理器 [English](2026-06-16-pnpm-over-yarn.md) | 中文 +Status: implemented + ## 问题 -本仓库最初使用 **Yarn 4** 搭配 `node-modules` linker 发布——这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时提供 Yarn 的 workspace 和 `yarn constraints`。它能用。但 Yarn 4 的 Plug'n'Play 血统使得 `node-modules` linker 成为非主流模式,而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent 构建、偶尔有人类贡献者阅读的仓库来说,「大多数工具和人所预期的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的答案。 +本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。 -切换成本目前处于最低点。本仓库尚无任何包发布(所有 package 均为 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到 (a) 解析并链接 `node_modules`,(b) 运行 workspace 脚本,(c) 强制执行 workspace 约束。唯一的 Yarn 专属资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](2026-06-11-tsdown-over-dumble.md)的逻辑一致:趁爆炸半径还小,把承重工具换成生态更健康的选项。 +切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到:(a) 解析并链接 `node_modules`,(b) 运行 workspace 脚本,(c) 强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 ## 决策 -采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定、经 Corepack 安装(与 Yarn 使用的机制相同): +采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同): -- **Workspace** 从 `package.json` 的 `workspaces` 数组加 `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml`(`vendor/*`、`packages/*`——相同的 glob;`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。 -- **严格符号链接 linker**(pnpm 默认)取代 Yarn 的 hoisted `node-modules` linker。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会让幽灵依赖(引用未声明的传递依赖)大声失败,这对一个以机械门禁为整体质量策略的仓库而言是一个*优点*(见[机械质量门禁](2026-06-11-quality-gates.md))。门禁套件——类型检查、lint、测试、构建、knip——是安全网,证明不存在此类幽灵引用。 -- **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非显式列入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild`、`lefthook`、`@google/genai`、`protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在将其扩展到安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。 -- **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`、使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,以 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:所有 package `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围匹配、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查 privacy。 -- 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词统一改为 `pnpm …` / `pnpm run …`。`yarn.lock` → `pnpm-lock.yaml`(lockfile v9)。`.gitignore` 将 `.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保留其上游的 `yarn` 示例不动。 +- **Workspaces** 从 `package.json` 的 `workspaces` 数组 + `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml`(`vendor/*`、`packages/*`——同样的 glob;`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。 +- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会让幻影依赖(引用未声明的传递依赖)大声失败,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.md))是一项*优势*。门禁套件(typecheck、lint、test、build、knip)是证明不存在此类幻影导入的安全网。 +- **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非将其加入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild`、`lefthook`、`@google/genai`、`protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在也应用于安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。 +- **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查 privacy。 +- 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词变为 `pnpm …` / `pnpm run …`。`yarn.lock` → `pnpm-lock.yaml`(lockfile v9)。`.gitignore` 将 `.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保持其上游 `yarn` 示例不变。 ## 曾考虑的替代方案 -- **保留 Yarn 4**:零变动,但押注于使用者更少的 linker 模式和绑定单一包管理器的约束引擎。 -- **npm workspaces**:无处不在,但没有约束机制,monorepo 人体工学也更弱。 -- **pnpm 搭配 hoisted linker**:迁移更平滑,但放弃了幽灵依赖安全性——而这正是迁移的首要正确性理由。 +- **保留 Yarn 4**——零变动,但押注于使用率较低的链接器模式和一个绑定单一包管理器的约束引擎。 +- **npm workspaces**——无处不在,但没有约束方案,monorepo 人体工学也较弱。 +- **pnpm 搭配提升式链接器**——迁移更平滑,但放弃了幻影依赖安全性,而这正是迁移的核心正确性理由。 ## 后果 -约束检查失去了 Yarn 的自动**修复**能力(`workspace.set()` 可以就地改写 manifest);tsx 脚本仅做检查,不通过时以非零退出码加消息退出。这是可接受的:CI 从未运行过 `--fix`,且需要手动改一行的情况很少。贡献者现在为 pnpm 而非 Yarn 运行 `corepack enable`;`pnpm exec lefthook install` 取代 `yarn lefthook install`(`postinstall` 钩子仍会运行 `lefthook install`)。 +约束检查失去了 Yarn 的自动**修复**能力(`workspace.set()` 能原地改写 manifest);tsx 脚本仅做检查,不通过时以非零退出码和消息退出。这是可接受的:CI 从未运行过 `--fix`,且需要手动编辑的情况很少。贡献者现在为 pnpm 而非 Yarn 运行 `corepack enable`;`pnpm exec lefthook install` 取代 `yarn lefthook install`(`postinstall` 钩子仍会运行 `lefthook install`)。 性能(迁移时在开发 NFS 文件系统上测量;单次运行样本,方差大——仅供方向性参考,非基准测试套件): | 场景 | Yarn 4 | pnpm 11 | |---|---|---| -| Cold (empty cache/store, no `node_modules`) | ~14 s | ~16 s | -| Warm relink (cache/store warm, `node_modules` removed) | ~12–14 s | ~15–22 s | -| Frozen, `node_modules` present (no-op revalidate) | ~2–8 s | ~0.5–7 s | +| 冷启动(空缓存/store,无 `node_modules`) | ~14 s | ~16 s | +| 热重链接(缓存/store 已热,`node_modules` 已删除) | ~12–14 s | ~15–22 s | +| 冻结,`node_modules` 存在(无操作重验证) | ~2–8 s | ~0.5–7 s | -在快速本地磁盘上,pnpm 的内容寻址 store 通常在冷/热安装上胜出,尤其在多次 checkout 的**磁盘占用**上优势明显(一个全局 store 通过硬链接进入每个 `node_modules`,而 Yarn 每个 worktree 复制约 279 MB——部分开发者日常保持约 10 个或更多 worktree)。这一去重优势在上述迁移时数据中**未**体现,因为测试 store 和 `node_modules` 位于不同文件系统,硬链接失效;在单文件系统的开发机或 CI 缓存上该优势成立。诚实的总结:在我们的 NFS 开发文件系统上,安装速度在噪声范围内不分伯仲;迁移的理由是生态对齐、幽灵依赖安全性和跨 checkout 磁盘去重——而非原始安装时间的胜出。 +在快速本地磁盘上,pnpm 的内容寻址 store 通常在冷/热安装中胜出,尤其在多个检出之间的**磁盘占用**方面优势明显(一个全局 store 通过硬链接接入每个 `node_modules`,而 Yarn 每个 worktree 复制约 279 MB——部分开发者经常为本仓库保持约 10 个或更多 worktree)。该去重优势在上述迁移时数据中**未能**体现,因为测试 store 和 `node_modules` 位于不同文件系统,硬链接失效;在单文件系统的开发机或 CI 缓存上则适用。诚实的总结:在我们的 NFS 开发文件系统上,安装速度在噪声范围内不分伯仲;迁移的理由是生态对齐、幻影依赖安全性和跨检出磁盘去重,而非原始安装时间的胜出。 -所有质量门禁(约束、类型检查、lint、doc-sync、100% test:coverage、构建、knip、publint、echo-agent 演示冒烟测试)在 pnpm 上原样通过,这是 linker 切换未引入幽灵依赖破坏的正确性证明。 +所有质量门禁(constraints、typecheck、lint、doc-sync、test:coverage 100%、build、knip、publint、echo-agent 演示冒烟测试)在 pnpm 上原样通过,这是链接器切换未引入幻影依赖破坏的正确性证明。 diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 9858ce0f2e..92e8c81d4b 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-17-ts-build-config.md: cf70014b5873f74da8476c21dd71feedb956f59f -2026-06-17-ts-build-config.zh.md: f70619de5a48c7040e816c54d21f81772a51a90f +2026-06-17-ts-build-config.zh.md: d3dd0fb13edd22f1ae365286cbf8144fa0bc3f69 diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md index f70619de5a..d3dd0fb13e 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md @@ -1,47 +1,46 @@ -# RFC:以 TSC 为核心的构建与统一 tsconfig - -Status: implemented +# RFC:TSC 优先的构建与单一 tsconfig [English](2026-06-17-ts-build-config.md) | 中文 +Status: implemented + ## 问题 -当时的 TypeScript 构建与类型检查配置存在以下问题: +此前的 TypeScript 构建与类型检查配置存在以下问题: -- `build` 使用 `tsc` 将 `packages/<group>/<pkg>` 和 `vendor/*` 下的 `.ts` 转换为 `.d.ts`,再用 `tsdown` 将 `.ts` 转换为打包后的 `.js`。这导致两个工具各自做一次 TypeScript 转换。 -- `typecheck` 倾向于通过一个根级 typecheck 配置来校验 package、vendor 源码、示例、测试和脚本。 +- `build` 使用 `tsc` 将 `packages/<group>/<pkg>` 和 `vendor/*` 下的 `.ts` 转换为 `.d.ts` 文件,然后使用 `tsdown` 将 `.ts` 转换为打包后的 `.js` 文件。这导致两个工具各自执行 TypeScript 转换。 +- `typecheck` 倾向于通过一个根目录的 typecheck 配置来校验 package、vendor 源码、示例、测试和脚本。 -目标是让 build 和 typecheck 使用一致的 tsconfig 边界与 TypeScript 解析/转换行为。build 应通过同一个编译器和配置生成 `.js`、`.d.ts`、`.js.map` 和 `.d.ts.map`,使发布产物与类型校验保持一致。 +目标是让构建与类型检查使用一致的 tsconfig 边界和 TypeScript 解析/转换行为。构建应通过单一编译器和配置生成 `.js`、`.d.ts`、`.js.map` 和 `.d.ts.map`,使发布产物与类型校验保持一致。 -验证过程中发现了若干具体技术问题和可能的路径: +验证过程中发现了若干具体的技术问题和可能的路径: -- `tsdown` 使用 `oxc` 做 TypeScript 转换,其行为与 `tsc` 不同。 +- `tsdown` 使用 `oxc` 进行 TypeScript 转换,其行为与 `tsc` 不同。 - `tsdown` 输出的打包 `.d.ts` 与 Cordis 内部的相对模块增强(module augmentation)结构冲突。 - - tsc 的输出受 `allowImportingTsExtensions` 影响,因此需要确保生成的 `.js` 不会 import `.ts` 文件,且生成的 `.d.ts` 保留 NodeNext/Node16 可接受的显式相对说明符。为此,包内相对导入在 TypeScript 源码中使用显式 `.ts` 说明符,由 `rewriteRelativeImportExtensions` 在输出的 JS 中将其改写为 `.js`。 - - `tsdown` 输出的打包 `.js` 与 `tsc -b` 逐文件输出的 `.js` 行为不同,例如 decorator 转换行为。 -- `vendor/*/src`、示例、测试和脚本无法全部以 plain-include 方式放入一个根级严格程序。 - - 在根级严格配置下直接对 `vendor/*/src` 做类型检查,会触发大量不属于本项目的类型错误。 - - `packages/*/*` 对 `vendor` 的依赖解析到 `vendor/*/lib`,以适应不同的 tsconfig 严格度。 - + - `tsc` 的输出受 `allowImportingTsExtensions` 影响,因此需要确保生成的 `.js` 文件不会导入 `.ts` 文件,且生成的 `.d.ts` 文件保留 NodeNext/Node16 接受的显式相对说明符。为此,包内相对导入在 TypeScript 源码中使用显式 `.ts` 说明符,由 `rewriteRelativeImportExtensions` 在输出的 JS 中将其重写为 `.js`。 + - `tsdown` 输出的打包 `.js` 与 `tsc -b` 逐文件输出的 `.js` 行为不同,例如装饰器转换行为。 +- `vendor/*/src`、示例、测试和脚本无法全部以 plain-include 方式纳入一个根目录的严格程序。 + - 在根目录严格配置下直接对 `vendor/*/src` 做类型检查,会触发大量不属于本项目所有权范围的类型错误。 + - `packages/*/*` 对 `vendor` 的包依赖解析到 `vendor/*/lib`,以适应不同的 tsconfig 严格度。 ## 决策 包内相对导入使用显式 `.ts` 说明符。 -`pnpm run build` 分两阶段: +`pnpm run build` 是两阶段构建: -- 阶段 1:`tsc -b tsconfig.build.json` 将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts` / `.d.ts.map`,忽略 `.js` / `.js.map`。 - - 构建项目使用 `tsc -b` 编译的 project-reference 图。例如,根 `tsconfig.build.json` 引用包和 vendor 的 tsconfig,校验并输出包/vendor 的构建结果。 -- 阶段 2:bundler 读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,不得读取 TypeScript 源码,也不得输出声明文件。 +- 阶段 1:`tsc -b tsconfig.build.json` 将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各 package 的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts` / `.d.ts.map`,忽略 `.js` / `.js.map`。 + - 构建项目使用 `tsc -b` 编译的 project-reference 图。例如,根 `tsconfig.build.json` 引用 package 和 vendor 的 tsconfig,校验并输出 package/vendor 的构建结果。 +- 阶段 2:打包器读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,禁止读取 TypeScript 源码或输出声明文件。 `tsdown` 不再负责 TypeScript 编译或声明文件输出。 `pnpm run typecheck` 以 build 模式运行根 `tsconfig.json`。 -- 根 `tsconfig.json` 是唯一的开发/类型检查项目。它以 `noEmit` 检查示例、测试和脚本,并通过 references 校验包/vendor 源码。 -- 被引用的包/vendor 项目保持与 build 相同的输出行为,因此 typecheck 可以刷新它们的 `lib/types` 产物,而无需使用单独的 no-emit 图。项目特有的严格度设置放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 -- 根 no-emit 项目禁用 `rewriteRelativeImportExtensions`;它不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的输出项目保持该改写启用。 +- 根 `tsconfig.json` 是唯一的开发/类型检查项目。它以 `noEmit` 方式检查示例、测试和脚本,并通过 references 校验 package/vendor 源码。 +- 被引用的 package/vendor 项目保持与 build 相同的输出行为,因此 typecheck 可以刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 +- 根 no-emit 项目禁用 `rewriteRelativeImportExtensions`;它不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。package/vendor 的 emit 项目保持重写开启。 -命令编排如下: +命令编排结构如下: ```sh pnpm run build: @@ -59,20 +58,20 @@ tsc -b tsconfig.json ## 曾考虑的替代方案 -- **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(decorator 转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。 -- **一个根级严格程序覆盖包、vendor、示例、测试和脚本**:vendor 源码在根级严格 flag 下会触发不属于本项目的类型错误;带有各项目独立严格度的 project references 才是可行的边界。 +- **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(装饰器转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。 +- **用一个根目录严格程序覆盖 package、vendor、示例、测试和脚本**:vendor 源码在根目录严格标志下会触发不属于本项目所有权范围的类型错误;带有逐项目严格度的 project references 才是可行的边界。 ## 后果 构建职责更加清晰: -- `packages/<group>/<pkg>` 和 `vendor/*` 下的每个模块都有一个本地 tsconfig,同时服务于 build、typecheck 以及直接运行源码的工具(如 `tsx` 和 `vitest`)。 -- `build` 命令使用 `tsconfig.build.json`。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,bundler 只负责 `lib/index.*`。 - - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明文件产物。 +- `packages/<group>/<pkg>` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `tsx` 和 `vitest`)。 +- `build` 命令使用 `tsconfig.build.json`。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 + - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明输出。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 - - `lib/types/*.js` 仅作为 bundler 输入,不得用作运行时入口或公开导入目标。 - - `lib/index.*` 是发布用的运行时产物,由 bundler(当前为 `tsdown`)生成。 -- `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 表面进行临时外部 ESM 消费方的类型检查,使声明说明符的回归在发布前即被捕获。 -- `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 + - `lib/types/*.js` 仅作为打包器输入,禁止用作运行时入口或公开导入目标。 + - `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。 +- `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。 +- `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,package 和 vendor 模块保持与 `build` 相同的输出行为。package 和 vendor 源码始终处于 project-reference 边界之后。 -Cordis vendor 副本现在与上游多了一处类型结构差异。上游同步时,必须重新应用该差异或明确将其退役。 +Cordis 的 vendor 副本现在与上游多了一处类型结构差异。在上游同步时,该差异必须被重新应用或明确废弃。 diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml index 3773aca2f5..2b7b62f141 100644 --- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-markdown-cross-link-lint.md: c802b4071abf652824647e4417cde3f518776353 -2026-06-18-markdown-cross-link-lint.zh.md: abbb5930acb18287effc7c47009b9e3e910f6c89 +2026-06-18-markdown-cross-link-lint.zh.md: 917580ffce71258896f3d23ef7efef4be0176949 diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md index abbb5930ac..917580ffce 100644 --- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md @@ -1,33 +1,33 @@ -# RFC:Markdown 交叉链接有效性 lint - -Status: implemented +# RFC:Markdown 交叉链接有效性检查 [English](2026-06-18-markdown-cross-link-lint.md) | 中文 +Status: implemented + ## 问题 -本仓库的文档通过相对路径互相链接:`[topic](../implemented/2026-…-….md)`、`[the cookbook](adding-a-tool.md)`、`[architecture.md](../../architecture.md)`。此前没有任何机制验证这些目标是否存在。一次重命名或移动会悄无声息地打断所有入站链接,直到读者点击时才会发现。[Doc-sync 强制](2026-06-11-doc-sync-enforcement.md)已经将两类文档漂移机械化了(不可编译的代码块、陈旧的事件分类体系表),[verify-md-wrap](2026-06-11-doc-sync-enforcement.md) 处理了第三类(硬换行的行文段落),但死链接是第四类同样可机械检查的问题,此前仍靠肉眼验证。 +本仓库的文档通过相对路径互相链接:`[topic](../implemented/2026-…-….md)`、`[the cookbook](adding-a-tool.md)`、`[architecture.md](../../architecture.md)`。此前没有任何机制验证这些目标是否存在。重命名或移动文件会静默破坏所有指向它的链接,且在读者点击之前不可见。[Doc-sync 强制](2026-06-11-doc-sync-enforcement.md)已经将两类文档漂移机械化(无法编译的代码块、陈旧的事件分类表),[verify-md-wrap](2026-06-11-doc-sync-enforcement.md) 覆盖了第三类(硬换行的段落),但死链是第四类同样可机械检查、却仍靠肉眼验证的问题。 -直接触发本门禁的案例是引入它的那次 RFC 目录重组:将 `docs/adr/` + `docs/rfc/` 统一为一个 `docs/rfc/`,下设 `proposed/`、`implemented/`、`rejected/` 子目录,手动改写了约四十条文档间链接。任何一条路径的手误都会让断链随代码一起合入,而没有任何东西能拦住它。 +触发本门禁的直接案例是引入它的那次 RFC 目录重组:将 `docs/adr/` + `docs/rfc/` 统一为一个 `docs/rfc/`,下设 `proposed/`/`implemented/`/`rejected/` 子目录,手动改写了约四十条文档间链接。任何一个手误路径都会让一条死链随代码入库,而没有任何东西能拦住它。 ## 决策 新增第四道 `doc-sync` 门禁 `verify-md-links`(`scripts/verify-md-links.ts`),风格与 `verify-md-wrap` 一致(tsx ESM、基于 AST、只验证不生成): -- 用 `mdast-util-from-markdown` + GFM 解析范围内的每个 Markdown 文件,遍历所有 `link`、`image` 和 `definition` 节点。 -- 仅当目标是**相对路径**时才检查。跳过带协议的 URL(`https:`、`mailto:` 等)、协议相对路径(`//host`)、根绝对路径(`/path`——在 checkout 中没有稳定基准)以及纯页内锚点(`#section`)。去除 `#fragment`/`?query`,相对于链接所在文件的目录解析路径,并断言该路径在磁盘上存在。 -- 只报告,不改写;发现第一条断链即以非零状态退出。 +- 使用 `mdast-util-from-markdown` + GFM 解析每个范围内的 Markdown 文件,遍历所有 `link`、`image` 和 `definition` 节点。 +- 仅当目标是**相对路径**时才检查。跳过带协议的 URL(`https:`、`mailto:` 等)、协议相对路径(`//host`)、根绝对路径(`/path`,在检出目录中没有稳定基准)以及纯页内锚点(`#section`)。剥除 `#fragment`/`?query`,相对于链接所在文件的目录解析路径,并断言目标在磁盘上存在。 +- 只报告、不改写;发现第一条死链即以非零状态退出。 -范围与其他门禁一致,另加 AGENTS.md 对和 `.agents/skills/` 下仓库自有的 agent skill Markdown(这些 skill 文件交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`,按真实路径去重(`CLAUDE.md` 符号链接解析到 AGENTS.md 文件)。该门禁接入 lefthook pre-push 钩子和 CI 都会运行的 `doc-sync` 脚本,因此断链在推送前就会在本地失败——与[机械质量门禁](2026-06-11-quality-gates.md)保持一致。 +范围与其他门禁一致,另外加上 AGENTS.md 对和 `.agents/skills/` 下仓库自有的 agent skill Markdown(这些 skill 文件交叉链接到 docs 目录,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`,按真实路径去重(`CLAUDE.md` 符号链接解析到 AGENTS.md 文件)。它接入 lefthook pre-push 钩子和 CI 都会运行的 `doc-sync` 脚本,因此死链在推送前就会在本地失败——与[机械化质量门禁](2026-06-11-quality-gates.md)一致。 -本门禁检查的是**文件存在性**,而非锚点有效性:链接到一个真实文件但带有 `#wrong-heading` 片段的仍然通过(文件可解析;片段被剥离)。 +本门禁检查的是**文件存在性**,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件可解析;片段被剥除)。 ## 曾考虑的替代方案 -**锚点级有效性检查**:更重且价值更低;实际造成问题的是文件级死链接。这一范围裁剪是有意为之:作者在链接到某个锚点时自行验证 `#fragment`。 +**锚点级有效性检查**:更重且价值更低;实际造成问题的是文件级死链。这一范围裁剪是有意为之:作者在链接到某个锚点时自行验证 `#fragment`。 ## 后果 -- 重命名或移动导致交叉链接悬空时,pre-push 钩子和 CI 会立即失败,而不是等读者点击死链接才发现。这使得引入本门禁的 RFC 重组具有自验证性:同一个 PR 既改写了四十条链接,也加入了证明无一悬空的检查。 -- `doc-sync` 链中多了一个快速 tsx 脚本;无新增依赖(mdast/GFM 技术栈已在 devDependencies 中供 `verify-md-wrap` 使用)。 -- 本门禁强制的约定——通过可机械检查的相对链接交叉引用文档,而非裸文字或编号——记录在 [docs/AGENTS.md](../../../AGENTS.md) 中,让作者知道这道门禁的存在及其原因。 +- 重命名或移动文件导致交叉链接悬空时,现在会在 pre-push 钩子和 CI 中失败,而不是等读者点击死链才发现。这使得引入本门禁的 RFC 重组具备自验证能力:改写四十条链接的同一个 PR 也添加了证明无一悬空的检查。 +- `doc-sync` 链中多了一个快速 tsx 脚本;无新增依赖(mdast/GFM 技术栈已作为 `verify-md-wrap` 的 devDependencies 存在)。 +- 本门禁强制的约定——通过可机械检查的相对链接引用文档,而非裸文本或编号——记录在 [docs/AGENTS.md](../../../AGENTS.md) 中,让作者知晓门禁的存在与原因。 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 2421ac85fc..4345634014 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-core-data-structures-catalog.md: 5f1232f2f0d0644d4043af217a7177451155030b -2026-06-20-core-data-structures-catalog.zh.md: d35a4d9d32eb59971bbf8b105d01dd38c0811fb0 +2026-06-20-core-data-structures-catalog.zh.md: 8ad4453890d8be5dc4e743d1f6f9aa6a9330ed17 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index d35a4d9d32..8ad4453890 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -6,55 +6,55 @@ Status: implemented ## 问题 -想要理解 harness 的读者可以在 [architecture.md](../../../architecture.md) 中找到它的*行为*(服务映射、会话/轮次/步骤生命周期、事件分类体系),但没有一个集中的地方描述它的*词汇*——那些行为所操作的数据结构。类型定义只存在于源码中,散落在各个 `packages/*/src/types.ts` 里,因此要理解「什么是 `Message`、`SessionEvent`、`StreamChunk`」就意味着直接阅读声明。一份行文目录会有帮助,但如果目录是对类型定义的转述或粘贴复制,那么字段一改它就会腐烂——而一份失去同步的类型文档比没有更糟,因为读者会信任它。 +一位想要理解 harness 的读者,可以在 [architecture.md](../../../architecture.md) 中找到它的*行为*(服务映射、session/turn/step 生命周期、事件分类体系),但没有一个集中的地方描述它的*词汇*——即行为所操作的数据结构。类型形状只存在于源码中,分散在各个 `packages/*/src/types.ts` 里,因此要理解「什么是 `Message`、`SessionEvent`、`StreamChunk`」就得直接阅读声明。一份行文目录会有帮助,但如果目录是对类型定义的转述或粘贴复制,那么字段一改它就会腐烂——而失去同步的类型文档比没有更糟,因为读者会信任它。 -因此这项工作包含两个交织的问题:**这样一份目录应当收录什么**(范围界定问题:一个 harness 有数十个跨包(package)的类型,全部堆上去对谁都没帮助),以及**如何防止粘贴的类型定义漂移**(持久性问题)。本 RFC 记录这两项决策。它的姊妹篇 [生成式 Cordis 事件 + 服务目录](2026-06-20-generated-cordis-catalog.md) 是*接线*轴向的补充:本篇编目数据结构,那篇编目移动它们的事件与服务。 +因此这项工作包含两个交织的问题:**这样的目录应当收录什么**(范围界定问题——一个 harness 有数十个跨包类型,全部堆上去对谁都没帮助),以及**如何防止粘贴的类型定义漂移**(持久性问题)。本 RFC 记录两项决策。它的姊妹篇 [生成式 Cordis 事件 + 服务目录](2026-06-20-generated-cordis-catalog.md) 是*接线*轴的补充:本篇编目数据结构,那篇编目传递数据结构的事件与服务。 ## 决策 -新建 `docs/core-data-structures/` 目录编目词汇,并新增 `verify-type-equiv` doc-sync(文档同步门禁)门禁,确保每一处粘贴的类型定义与源码逐字节一致。 +新建 `docs/core-data-structures/` 文件夹编目词汇,并新增 `verify-type-equiv` doc-sync(文档同步门禁)门禁,确保每处粘贴的类型定义与源码逐字节一致。 -### 什么算「核心」——主干与 seam 的分界线 +### 何为"核心"——主干与 seam 的分界线 -范围界定不是自上而下拍板的,而是将候选定义逐一对照具体的边界类型反复测试,直到一条规则在所有案例中都成立。决定性的测试是 `BashExecRequest`/`BashExecSpec`/`BashRunResult`:bash 是一个能力 *seam*,不属于 agent loop 主干;如果这些算「核心」,那「核心」就等于*所有跨包词汇*,目录就是一份平铺的全量转储;如果它们不算,「核心」就意味着*中央主干*,bash 词汇属于子页面。后者胜出,由此确定了整体结构:一个**分层目录**,而非一份平铺文档。 +范围界定并非自上而下拍定,而是将候选定义逐一对照具体的边界类型反复测试,直到一条规则在所有案例中都成立。决定性的测试是 `BashExecRequest`/`BashExecSpec`/`BashRunResult`:bash 是一个能力 *seam*,不属于 agent loop(智能体循环)主干;如果这些算"核心",那么"核心"就意味着*所有跨包词汇*,目录沦为平铺罗列;如果不算,"核心"就意味着*中央主干*,bash 词汇归入子页面。后者胜出,由此确定了整体结构:一个**分层文件夹**,而非一份平铺文档。 -解决剩余案例的规则:***你编写、持有或接收的类型是核心;为其提供类型推导、渲染或持久化的机制是子页面细节。*** 逐一验证如下: +确定其余案例的规则是:***你编写、持有或接收的类型是核心;为其提供类型推导、渲染或持久化的机制是子页面细节。*** 逐一验证如下: -- 一个数据结构是**核心**的,如果它流经 agent loop 主干——无论加载了哪些插件,循环在每个轮次都持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄)——**或者**它是插件作者面向某条流水线编写的唯一标题类型(`ToolDefinition`)。 -- `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标题类型,撰写重要性覆盖了严格的「流经主干」规则。但它的类型推导机制——`SchemaSpec`/`InferArgs` DSL——是子页面细节(你编写的是 `ToolDefinition`;为其提供类型推导的机制你不直接接触)。这就是主干与 seam 分界线的精确表述。 -- `ToolSchema` 是核心(它是 `GenerateOptions` 的字段,而 `GenerateOptions` 是流经每个步骤的模型请求),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 +- 一个数据结构是**核心**的,如果它流经 agent loop 主干——无论加载了哪些插件,循环在每个轮次都会持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄)——**或者**它是插件作者面对某条流水线时编写的唯一标志性类型(`ToolDefinition`)。 +- `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标志性类型,撰写重要性压过了严格的"流经主干"规则。但它的类型推导机制——`SchemaSpec`/`InferArgs` DSL——是子页面细节(你编写的是 `ToolDefinition`;为其提供类型推导的机制你并不直接接触)。这就是主干与 seam 分界线的精确表述。 +- `ToolSchema` 是核心(它是 `GenerateOptions` 的一个字段,而 `GenerateOptions` 是流经每个步骤的模型请求),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 - 工具展示词汇(`ToolCallView`/`ToolResultView` 等)、`SessionPersistence` 持久性 seam 以及 bash 词汇是子页面。 -`core.md` 是一份**自包含的主干文档**:它给出每个主干结构的确切类型定义,配以最少的行文,并链接到各 seam 细节的子页面。子页面包括 `llm-streaming.md`、`session.md`、`persistence.md`(沿内存模型与持久性 seam 的分界从 session 中拆出)、`tools.md` 和 `bash.md`。 +`core.md` 是一份**自包含的主干文档**:它给出每个主干结构的确切类型定义,辅以最少的行文,并链接到子页面获取各 seam 的细节。子页面包括 `llm-streaming.md`、`session.md`、`persistence.md`(沿内存模型与持久性 seam 的分界线从 session 拆出)、`tools.md` 和 `bash.md`。 -### `ts type-equiv` 机制——逐字且防漂移 +### `ts type-equiv` 机制——既逐字又防漂移 -持久性要求很具体:文档应展示**逐字**的当前类型定义(让读者看到真实形状,而非转述),**并且**机械地保证与源码一致。仓库已经能编译围栏 ` ```ts ` 块(`doc-typecheck`),但一个真正通过类型检查的块需要 import 噪音,且只能证明*可赋值性*而非*逐字节相等*——一个改了名但类型相同的字段仍能通过。因此: +持久性需求很具体:文档应当展示**当前类型定义的原文**(让读者看到真实形状,而非转述),**并且**机械地保证与源码一致。仓库已经能编译围栏 ` ```ts ` 块(`doc-typecheck`),但一个真正可编译的块需要 import 噪音,且只证明*可赋值性*而非*字节相等*——一个类型相同但改了名的字段会通过。因此: -- 类型定义逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。`doc-typecheck` 识别该围栏并跳过它(裸定义不能独立编译),并**将其排除在 opt-out 比例之外**——它是一个独立检查的类别,而非未检查的草稿。 -- 新增 `scripts/verify-type-equiv.ts`,通过 TypeScript 解析器提取每个块,并对声明的符号断言**逐字源码匹配**——之所以选择这种方式而非编译式 `_Check` 可赋值性断言,正是因为逐字节相等而非可赋值性才是我们需要的属性。 -- 来源信息保存在中央 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有条目腐烂。 -- 接入 `doc-sync`,因此与其他文档门禁在同一个 lefthook pre-push 和 CI 路径中运行。 +- 类型定义逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。`doc-typecheck` 识别该围栏并跳过它(裸定义不能独立编译),且**将其排除在 opt-out 比例之外**——它是一个独立检查的类别,而非未检查的草稿。 +- 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其与声明的符号**逐字节匹配源码**——之所以选择这种方式而非编译式 `_Check` 可赋值性断言,正是因为我们需要的属性是字节相等,而非可赋值性。 +- 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。 +- 接入 `doc-sync`,因此与其他文档门禁在同一条 lefthook pre-push 和 CI 路径中运行。 ### 维护是作者的职责,门禁作为兜底 -`verify-type-equiv` 能捕获已记录类型的*粘贴漂移*,但无法告诉你一个全新的核心类型没有被记录。因此 AGENTS.md 和 `dsh-code-review` skill 已更新,要求在变更添加或重塑已记录类型时同步更新目录——门禁处理漂移,人处理新增表面。 +`verify-type-equiv` 能捕获已记录类型的*粘贴漂移*,但无法告诉你一个全新的核心类型没有被记录。因此 AGENTS.md 和 `dsh-code-review` skill(技能)已更新,要求在变更添加或重塑已记录类型时同步更新目录——门禁处理漂移,人处理新增表面。 ## 曾考虑的替代方案 -- **平铺转储所有跨包词汇**:`BashExecRequest` 测试案例否决了它。如果 seam 词汇算「核心」,目录对谁都没帮助;分层的主干与 seam 结构胜出。 -- **编译式 `_Check` 可赋值性断言**替代逐字源码匹配:否决,因为逐字节相等而非可赋值性才是我们需要的属性——一个改了名但类型相同的字段能通过可赋值性检查。 -- **来源信息作为行文中的指令注释**:否决,改用中央 manifest;其强制的 1:1 对应确保不会有块被静默漏检,也不会有条目腐烂。 +- **平铺罗列所有跨包词汇**:`BashExecRequest` 测试案例否决了它。如果 seam 词汇算"核心",目录对谁都没帮助;分层的主干与 seam 结构胜出。 +- **编译式 `_Check` 可赋值性断言**替代逐字节源码匹配:否决,因为我们需要的属性是字节相等而非可赋值性——一个类型相同但改了名的字段会通过可赋值性检查。 +- **来源信息作为行文中的指令注释**:否决,改用集中 manifest;其强制的 1:1 对应确保一个块永远不会被静默漏检,一条条目也永远不会腐烂。 ## 验证教训 主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及 session/persistence 拆分的逐一测试。 -`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是 manifest 中列出的文档。否则未登记的 `type-equiv` 块会逃脱所声称的一对一检查。因此门禁将此类块报告为遗留块。本 RFC 将这条快速失败的扫描规则与主干/seam 分界和逐字匹配决策一并记录;生成式 Cordis 目录在[其 RFC](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 +`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅仅是 manifest 中列出的文档。否则一个未登记的 `type-equiv` 块会逃脱所声称的一对一检查。因此门禁将此类块报告为遗留块。本 RFC 将这条快速失败的扫描规则与主干-seam 分界线和逐字节匹配决策一并记录;生成式 Cordis 目录在[其 RFC](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 ## 后果 -- 词汇现在有了一个**不会静默漂移**的唯一归属地:源码中的字段重命名会在 pre-push 钩子和 CI 中使 `verify-type-equiv` 失败,直到粘贴内容被刷新。 -- 主干与 seam 分界线是一个可复用的范围界定工具,而非一次性决策:同一条「你编写/持有/接收的东西是核心;为其提供类型推导/渲染/持久化的机制是细节」规则,后来也被用于界定事件/服务目录的 harness 层与继承层分层。 -- `ts type-equiv` 围栏是继 ` ```ts `(编译)和 ` ```ts ignore-check `(草稿)之后的第三种文档块类别。后续又新增了第四种 ` ```ts cordis-catalog `(生成签名),复用了相同的跳过并排除处理。 +- 词汇现在有了一个**不会静默漂移**的唯一归属:源码中的字段重命名会在 pre-push 钩子和 CI 中导致 `verify-type-equiv` 失败,直到粘贴内容被刷新。 +- 主干与 seam 分界线是一个可复用的范围界定工具,而非一次性的:同一条「你编写/持有/接收的东西是核心;为其提供类型推导/渲染/持久化的机制是细节」规则,后来也被用于界定事件/服务目录的 harness 层与继承层分层。 +- ` ```ts type-equiv ` 围栏是继 ` ```ts `(编译)和 ` ```ts ignore-check `(草稿)之后的第三种文档块类别。后续的姊妹门禁又增加了第四种 ` ```ts cordis-catalog `(生成签名),复用了相同的跳过并排除处理。 - 添加或重塑核心类型现在附带一项文档义务,作者必须履行(门禁无法检测缺失的*新*类型),由 `dsh-code-review` 检查清单兜底。 diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml index ee4e1d24fc..9f7e982ff7 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generated-cordis-catalog.md: 6b451e31965f8f00210aa927ed236fed28699351 -2026-06-20-generated-cordis-catalog.zh.md: 9c7150ff5f12d4d9e4a13158acdf8f52e84fd95a +2026-06-20-generated-cordis-catalog.zh.md: 5550d07b5f5635d1da6496e35049c5725329e114 diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md index 9c7150ff5f..5550d07b5f 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC:生成式 Cordis 事件 + 服务目录 +# RFC:生成式 Cordis 事件与服务目录 Status: implemented @@ -6,36 +6,36 @@ Status: implemented ## 问题 -插件作者需要两个参考面,而此前没有任何单一文档能提供:他们可以监听的每一个 Cordis **事件**(含精确签名与分发模式),以及他们可以调用的每一个 `ctx.<key>` **服务**(含精确接口)。相关信息已经存在,但散落各处:`docs/architecture.md` 中一张手工维护的事件分类*表格*(名称 + 行文描述的 Mode/Purpose,由 `verify-event-taxonomy` 做名称集合校验)、一张服务映射表(8 行角色描述),以及 `interface Events` / `interface Context` 声明本身。分类表还有一个盲区:它无法捕获全新的*未记录*事件——名称集合校验器只检查两侧已有的名称。 +插件作者需要两个参考面,而此前没有任何单一文档能提供:他们可以监听的每一个 Cordis **事件**(含精确签名与分发模式),以及他们可以调用的每一个 `ctx.<key>` **服务**(含精确接口)。相关信息虽然存在,但散落各处:`docs/architecture.md` 中一张手工维护的事件分类*表格*(名称 + 行文描述的 Mode/Purpose,由 `verify-event-taxonomy` 做名称集合校验)、一张服务映射表(8 行角色描述),以及 `interface Events` / `interface Context` 声明本身。分类表格还有一个盲区:它无法捕获全新的*未记录*事件——名称集合校验器只检查两侧已有的名称。 -这是[核心数据结构目录](../../../core-data-structures/core.md)([对应 RFC](2026-06-20-core-data-structures-catalog.md))在连线轴上的互补件:那份目录记录 agent loop 流转的*数据结构*(经校验的手工粘贴);本目录记录流转它们的*事件与服务*。 +这是 [core-data-structures 目录](../../../core-data-structures/core.md)([其 RFC](2026-06-20-core-data-structures-catalog.md))在连线轴上的补充:后者编目的是 agent loop(智能体循环)流转的*数据结构*(经校验的手工粘贴);本 RFC 编目的是移动这些数据结构的*事件与服务*。 ## 决策 -从源码生成目录,而非手工维护表格再校验子集。 +从源码生成目录,取代手工维护表格并校验子集的方式。 -`scripts/gen-cordis-catalog.ts` 使用 TypeScript 编译器 API,从声明和源码 JSDoc 分别输出事件参考与服务参考。事件包含分发模式;服务包含公开签名。确定性的 `--write` 与 `--check` 模式使两个页面成为生成产物,新鲜度由 doc-sync 强制。 +`scripts/gen-cordis-catalog.ts` 使用 TypeScript 编译器 API,从声明和源码 JSDoc 分别输出事件参考与服务参考。事件包含分发模式;服务包含公开签名。确定性的 `--write` 和 `--check` 模式使两个页面成为生成产物,新鲜度由 `doc-sync`(文档同步门禁)强制保障。 -纯生成在这里是正确的,因为代码库足够规范,AST 即全部真相:每个事件/服务名称都是字符串字面量,能往返映射到一个静态声明——没有动态命名的事件,也没有仅运行时存在的服务。因此生成的文档不可能出错,并且从结构上消除了未记录事件的缺口(生成器枚举源码,而非检查手写子集)。 +纯生成在此处是正确的,因为代码库足够规范,AST 就是全部事实:每个事件/服务名称都是字符串字面量,可以往返映射到静态声明——不存在动态命名的事件,也不存在仅运行时的服务。因此生成的文档不可能出错,且从结构上消除了未记录事件的缺口(生成器枚举源码,而非校验手写子集)。 具体选择: -- **`@mode` 标签,交叉校验。** 每个 harness 事件的 JSDoc 携带显式的 `@mode emit|waterfall|parallel|serial` 标签;缺少标签时生成器直接报错。当签名形状具有结论性时——尾部参数为 `next: () => …` 在结构上即为 waterfall——生成器断言标签与之一致,矛盾时直接报错。emit/parallel/serial 的区分在结构上不可见(`session/flush` 返回 `Promise<void> | void` 且无 `next`,有序的 `agent/pre-step` 检查点亦然),因此信任标签。撰写规则见 [AGENTS.md](../../../../AGENTS.md)。 -- **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/HMR/timer)是插件同样可见的固定 vendor 源;它以精简形式渲染(名称 + 一行说明 + 源码指针),数据来自生成器中的一张手工策展表,而**不是**遍历 vendor AST——cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 表面仅在有意的 vendor 同步时才变化。 -- **交叉链接到数据结构目录。** 签名中出现的类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition` 等)链接到记录该类型的核心数据结构页面。映射是生成器中一个小型手工策展的 const,而**不是** `type-equiv.manifest.json`——后者记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 -- **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,`doc-typecheck` 识别并跳过它(裸签名片段不能独立编译),不计入 opt-out 比例——与 `type-equiv` 块的处理方式相同。 +- **`@mode` 标签,交叉校验。** 每个 harness 事件的 JSDoc 携带一个显式的 `@mode emit|waterfall|parallel|serial` 标签;缺少标签时生成器直接报错。当签名形状具有决定性时——尾部参数为 `next: () => …` 在结构上即为 waterfall(瀑布式事件)——生成器断言标签与之一致,矛盾时直接报错。emit/parallel/serial 的区别在结构上不可见(`session/flush` 返回 `Promise<void> | void` 且无 `next`,有序的 `agent/pre-step` 检查点亦然),因此信任标签。编写规则见 [AGENTS.md](../../../../AGENTS.md)。 +- **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/hmr/timer)是插件同样可见的固定 vendor 源码;它从生成器中一张人工维护的表格简洁渲染(名称 + 一行描述 + 源码指针),而**非**遍历 vendor AST。原因是 cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 接口面仅在有意的 vendor 同步时才变化。 +- **交叉链接到数据结构目录。** 签名中的类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition` 等)链接到记录该类型的 core-data-structures 页面。映射是生成器中一个小型的人工维护常量,而**非** `type-equiv.manifest.json`——后者记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 +- **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,`doc-typecheck` 识别后跳过(裸签名片段不能独立编译),并排除在 opt-out 比例之外——与 `type-equiv` 块获得相同待遇。 -本决策**取代** [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)中的事件分类部分:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射角色表作为策展行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 与 verify-type-equiv 不受影响。 +本决策**取代** [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)中事件分类的那一半:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射的角色表格作为人工行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 和 verify-type-equiv 不受影响。 ## 曾考虑的替代方案 -- **校验而非生成(退役的分类检查所做的事)**:*仅对此表面*反转了方向。这里的数据可以机械地完整获取,因此生成严格强于名称集合校验(完整签名、不会漂移、能捕获未记录事件)。 -- **遍历 vendor AST 以获取继承层**:否决,改用策展表。cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段,且固定的 vendor 表面仅在有意同步时才变化。 -- **复用 `type-equiv.manifest.json` 作为签名交叉链接映射**:否决,改用小型手工策展 const。manifest 记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 +- **校验而非生成(退役的分类检查所做的事)**:*仅对本参考面*反转了这一策略。此处的数据可以机械地完整获取,因此生成严格强于对手工表格做名称集合校验(完整签名、不会漂移、能捕获未记录事件)。 +- **遍历 vendor AST 以获取继承层**:否决,改用人工维护表格。cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段,且固定的 vendor 接口面仅在有意同步时才变化。 +- **复用 `type-equiv.manifest.json` 作为签名交叉链接映射**:否决,改用小型人工维护常量。manifest 记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 ## 后果 -- 目录不会漂移:源码变化而已提交文件未反映时,`verify-cordis-catalog` 在 pre-push 钩子和 CI 中失败。新事件缺少 `@mode` 标签、或标签与签名矛盾时,生成器直接报错。 -- 事件的行文描述现在只有一个归属地——声明处的 JSDoc。JSDoc 写得薄,目录条目就薄,这迫使作者在源头做好文档(生成器是 AGENTS.md「每个导出都有语义 JSDoc」规则的强制函数)。 -- 继承层是手工摘要的,因此 vendor 同步若增加或重命名了 cordis-core 事件或 `ctx` 成员,需要同步编辑 `gen-cordis-catalog.ts` 中的策展表。这是不遍历固定 vendor 源的有意代价;变化很少,且在生成器中有明确标注。 -- `verify-event-taxonomy.ts` 被删除,`docs/architecture.md` 的事件表格消失;之前链接到特定表格行的人现在会落到生成目录上。 +- 目录不会漂移:源码变更而已提交文件未反映时,`verify-cordis-catalog` 在 pre-push 钩子和 CI 中失败。新事件缺少 `@mode` 标签,或标签与签名矛盾,生成器直接报错。 +- 事件的行文描述现在有了唯一归属地——声明处的 JSDoc。JSDoc 写得单薄,目录条目就单薄,这迫使作者在源码处做好文档(生成器是 AGENTS.md「每个导出都有语义 JSDoc」规则的强制函数)。 +- 继承层是手工摘要,因此 vendor 同步若新增或重命名了 cordis-core 事件或 `ctx` 成员,需要同步编辑 `gen-cordis-catalog.ts` 中的人工维护表格。这是不遍历固定 vendor 源码的有意代价;它很少变化,且在生成器中有明确标注。 +- `verify-event-taxonomy.ts` 被删除,`docs/architecture.md` 的事件表格也已移除;之前链接到特定表格行的人现在会落在生成目录上。 diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml index 97b96e7315..cc4642657c 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-rfc-classification.md: 201852a209be7f40b05de45d148a36b9185767a3 -2026-06-20-rfc-classification.zh.md: 38eb920ac216e69087f0c84c95cdd9effca7b9b3 +2026-06-20-rfc-classification.zh.md: 554ac8014719c99ed447a33ff843c40fde761eed diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md index 38eb920ac2..554ac80147 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md @@ -1,48 +1,48 @@ # RFC:通过路径编码的子目录对 RFC 进行分类 -Status: implemented - [English](2026-06-20-rfc-classification.md) | 中文 +Status: implemented + ## 问题 -`docs/rfc/` 此前仅按**生命周期**分组:`proposed/`/`implemented/`/`rejected/`。没有任何机制记录每篇 RFC 属于哪一*类*决策。索引是每个生命周期下的一个扁平列表,无法按需筛选「所有精简类」或「所有测试策略类」决策。同一天落地的一批精简类 RFC 让这个缺口变得具体:浏览 `proposed/` 的读者无法在不逐一打开文件的情况下区分新能力、移除和工具策略变更。 +`docs/rfc/` 过去仅按**生命周期**分组 RFC:`proposed/`/`implemented/`/`rejected/`。没有任何机制记录每个 RFC 属于哪一*类*决策。索引在每个生命周期下只是一个扁平列表,无法按需筛选「所有简化类」或「所有测试策略类」决策。一批简化类 RFC 在同一天落地后,这个缺口变得具体:浏览 `proposed/` 的读者无法在不逐一打开文件的情况下区分新能力、移除和工具策略变更。 -本仓库的一贯倾向是[机械质量门禁优先于行文指南](2026-06-11-quality-gates.md):不被机器检查的约定终将腐烂。因此这里的分类体系必须可强制执行,而非靠自觉的文件头。 +本仓库一贯的倾向是[机械质量门禁优于行文规范](2026-06-11-quality-gates.md):不被机器检查的约定终将腐烂。因此这里的分类方案必须可强制执行,而非靠自觉的文件头。 ## 决策 -增加第二个维度——RFC 的**类别**——并将其编码在路径中:`{lifecycle}/{class}/yyyy-mm-dd-topic.md`。文件夹*就是*标签。文件的位置声明其类别,封闭集合是「这些文件夹且仅限这些」,而既有的 [verify-md-links](2026-06-18-markdown-cross-link-lint.md) 门禁已经保护了移动文件所需的路径重写。 +增加第二个维度——RFC 的**类别**——并将其编码在路径中:`{lifecycle}/{class}/yyyy-mm-dd-topic.md`。文件夹本身就是标签。文件的位置声明其类别,封闭集合是「这些文件夹且仅限这些」,而既有的 [verify-md-links](2026-06-18-markdown-cross-link-lint.md) 门禁已经保护了移动文件所需的路径重写。 ### 六个类别的封闭集合 -| 类别 | 覆盖范围 | +| 类别 | 涵盖范围 | |---|---| | `feature` | 面向用户或模型的新能力。 | -| `bug-fix` | 修正缺陷或弥补事后复盘暴露的缺口。 | -| `simplification` | 移除代码、行为或接口面,不增加新能力。 | -| `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇是什么。 | -| `process` | 围绕代码的工具、策略或工作流,不涉及运行时行为。 | +| `bug-fix` | 修正缺陷或填补事后复盘暴露的空白。 | +| `simplification` | 移除代码、行为或对外表面积,不引入新能力。 | +| `architecture` | 关于**交付源码**的结构性决策——包(package)之间的关系、运行时词汇。 | +| `process` | 围绕代码的工具、策略或工作流,而非运行时行为。 | | `testing` | 测试基础设施与策略。 | -`architecture` 与 `process` 的分界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。本 RFC 本身是一项 `process` 决策——它改变的是仓库的组织方式和门禁,而非 harness 在运行时的行为——因此它位于 `implemented/process/` 下。 +`architecture` 与 `process` 的分界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。本 RFC 本身是一个 `process` 决策——它改变的是仓库的组织方式和门禁,而非 harness 的运行时行为——因此它位于 `implemented/process/` 下。 ### 两道门禁 -两者都是 `doc-sync` 的成员,风格与 `verify-md-wrap` 一致(tsx ESM,只校验不生成,首个违规即以非零退出码退出): +两者都是 `doc-sync`(文档同步门禁)的成员,风格与 `verify-md-wrap` 一致(tsx ESM,只校验不生成,首个违规即以非零退出码退出): -- **`scripts/verify-rfc-classification.ts`**:封闭集合与索引新鲜度(freshness)。它断言每个生命周期文件夹下的文件都位于规范集合中的某个类别文件夹内(直接放在生命周期根目录的 `.md`,或未知的类别文件夹,都会失败),并断言生成的 [INDEX.md](../../INDEX.md) 与从目录树重新渲染的结果逐字节一致(见[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md))。规范类别集合以 `const` 形式定义在 `scripts/rfc-index.ts` 中——这是与生成器共享的机器真源——[README](../../README.md) 以行文形式记录它;类别*描述*保持手写,索引则是生成的。 -- **`scripts/verify-doc-refs.ts`**:源码注释中的文档引用。RFC 路径不仅在 Markdown 中被引用,也出现在 TypeScript 文档注释中(根相对路径,如 `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`)。`verify-md-links` 从未扫描过这些引用,因此重组可能会悄悄使它们变成悬空引用。此门禁扫描 `packages/**` 和 `examples/**` 下仓库自有的 `.ts` 文件(排除构建产物 `lib/` 和 `vendor/`),查找 `docs/….md` 形式的 token,将每个根相对路径解析并断言其存在。它要求 `.md` 扩展名,因此无扩展名的行文引用(`docs/postmortem/0001`、`docs/architecture.md § Extending The Harness`)不受影响。 +- **`scripts/verify-rfc-classification.ts`**——封闭集合与索引新鲜度。它断言生命周期文件夹下的每个文件都位于规范集合中的某个类别文件夹内(生命周期根目录下的散落 `.md` 或未知类别文件夹均判定失败),并断言生成的 [INDEX.md](../../INDEX.md) 与从目录树重新渲染的结果逐字节一致(见[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md))。规范类别集合以 `const` 形式定义在 `scripts/rfc-index.ts` 中——这是与生成器共享的机器真源——而 [README](../../README.md) 以行文形式记录它;类别*描述*保持手写,索引由机器生成。 +- **`scripts/verify-doc-refs.ts`**——源码注释中的文档引用。RFC 路径不仅被 Markdown 引用,也被 TypeScript 文档注释引用(以仓库根为起点的路径,如 `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`)。`verify-md-links` 从未扫描过这些引用,因此重组可能使它们静默失效。此门禁扫描 `packages/**` 和 `examples/**` 下仓库自有的 `.ts` 文件(排除构建产物 `lib/` 和 `vendor/`),查找 `docs/….md` 形式的 token,将每个以仓库根为起点的路径解析并断言其存在。它要求 `.md` 扩展名,因此无扩展名的行文引用(`docs/postmortem/0001`、`docs/architecture.md § Extending The Harness`)不受影响。 ## 曾考虑的替代方案 -- **在每个文件中加一行 `Classification:` 行文**(紧挨 `Status:`),由门禁解析。可行,但它把路径已经能承载的事实重复到了文件内,而且这一行可能与所在文件夹不一致。路径编码让标签与其存储合二为一——没有需要保持同步的东西。 -- **设立 `refactor` 类别。**它与 `simplification` 几乎完全重叠;唯一有人试图用来区分的标准是「可观测行为是否改变?」,而 `simplification` 已经编码了这一点(它不改变)。一个类别,不要两个。 -- **从文件系统自动生成索引。**此处最初否决,以保持索引手写;后来被[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md)取代——当堆叠的提案波使手写表格成为仓库中冲突最频繁的文档区域后,列表改为完全生成的 [INDEX.md](../../INDEX.md),而 README 行文保持人工策展。 +- **在每个文件中添加 `Classification:` 行文行**(紧邻 `Status:`),由门禁解析。可行,但它将路径已能承载的事实重复到文件中,且行内容可能与所在文件夹不一致。路径编码使标签与其存储合二为一,没有需要保持同步的东西。 +- **设立 `refactor` 类别。** 与 `simplification` 几乎完全重叠;唯一有人试图用来区分的标准是「可观察行为是否改变?」,而 `simplification` 已经编码了这一点(它不改变)。一个类别即可,无需两个。 +- **从文件系统自动生成索引。** 此处最初否决,以保持索引手写;后被[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md)取代——当堆叠的提案潮使手写表格成为仓库中冲突最频繁的文档区域后,列表改为完全生成的 [INDEX.md](../../INDEX.md),而 README 行文保持人工维护。 ## 后果 -- 每篇 RFC 现在都位于一个类别文件夹下,索引在每个生命周期内按类别分组。读者扫一个标题就能看到所有精简类或所有测试类决策。 -- `doc-sync` 链中多了两个快速 tsx 脚本;无新增依赖(mdast/GFM 栈已因 `verify-md-wrap`/`verify-md-links` 而存在)。 -- 新增类别是一个刻意的动作:修改 `scripts/rfc-index.ts` 中的 `const` 以及 [Classification 章节](../../README.md#classification),而不是仅仅 `mkdir` 一个文件夹。门禁会拒绝未知文件夹,因此临时类别无法悄悄混入。 -- 源码注释中的文档引用现在也受门禁保护:一个被移动或重命名的文档如果被 `.ts` 注释引用,pre-push 钩子就会失败,从而封堵了 `verify-md-links` 在结构上无法看到的一类漂移。 +- 每个 RFC 现在都位于一个类别文件夹下,索引在每个生命周期内按类别分组。读者只需扫一个标题即可看到所有简化类或所有测试类决策。 +- `doc-sync` 链中多了两个快速 tsx 脚本;无新依赖(mdast/GFM 栈已因 `verify-md-wrap`/`verify-md-links` 而存在)。 +- 新增类别是一个刻意的动作:修改 `scripts/rfc-index.ts` 中的 `const` 和 [Classification 章节](../../README.md#classification),而非仅仅 `mkdir` 一个文件夹。门禁拒绝未知文件夹,因此临时类别无法悄悄混入。 +- 源码注释中的文档引用现在也受门禁保护——一个被移动或重命名的文档如果被 `.ts` 注释引用,pre-push 钩子就会失败,堵住了 `verify-md-links` 在结构上无法看到的一类漂移。 diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml index 852f69db42..8ecca3648d 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-tool-schema-catalog.md: 9a99fafd36b3546be4f51a7cd9e9a47fdaaf4c2d -2026-07-02-tool-schema-catalog.zh.md: 373c681fa5870696645b138f12cad7f296434184 +2026-07-02-tool-schema-catalog.zh.md: 5860d1617ce6592c8665e4cb59304b0f6d35c99a diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md index 373c681fa5..5860d1617c 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -1,55 +1,55 @@ # RFC:生成式工具 schema 目录(启动并采集) -Status: implemented - [English](2026-07-02-tool-schema-catalog.md) | 中文 +Status: implemented + ## 问题 -仓库此前没有一份统一的参考,列出实际暴露给模型的工具名称、描述与 JSON Schema。源码声明分散各处且在运行时组合,而既有的 Cordis 目录和数据结构目录覆盖的是接线与词汇,而非工具本身。 +仓库此前没有一份统一的参考文档来记录实际暴露给模型的工具名称、描述与 JSON Schema。源码声明分散各处且在运行时组合,而既有的 Cordis 目录和数据结构目录覆盖的是接线与词汇,而非工具。 ## 决策 -通过**启动每个工具插件并读取其注册的 schema** 来生成目录,而非解析源码。`scripts/gen-tool-catalog.ts` 将每个已发布的工具包(package)挂载到一个全新的 Cordis `Context` 上(带 `SystemPrompt` + `ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`(即发送给模型的 `ToolSchema[]`),dispose 上下文,然后为每个包渲染一个 `## <package>` 小节,每个工具对应一个 ` ```json ` 的 `parameters` 块。它沿用 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形态:默认 `--write` 重新生成,`--check` 在已提交副本陈旧时失败,输出是确定性的(按 manifest 排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)运行在 doc-sync 内部,因此新鲜度门禁与其他文档门禁一样在 lefthook pre-push 和 CI 路径中触发。 +通过**启动每个工具插件并读取其注册的 schema** 来生成目录,而非解析源码。`scripts/gen-tool-catalog.ts` 将每个已发布的工具包(package)挂载到一个新的 Cordis `Context`(带 `SystemPrompt` + `ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`(即发送给模型的 `ToolSchema[]`),dispose(资源释放)该 context,然后为每个包渲染一个 `## <package>` 小节,每个工具一个 ` ```json ` 的 `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI(命令行界面)形态一致:默认 `--write` 重新生成,`--check` 在已提交副本陈旧时失败,输出是确定性的(按 manifest(元数据清单)排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 doc-sync(文档同步门禁)内运行,因此新鲜度门禁在 lefthook pre-push 和 CI 路径中与其他文档门禁一同触发。 -### 为什么启动而非解析(核心论点) +### 为何启动而非解析(核心要点) -Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是字符串字面量,能往返映射到静态声明——AST 就是全部事实。**工具 schema 不是静态可知的**,因此同样的技术会产出一份说谎的文档: +Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是字符串字面量,可以往返映射到静态声明——AST 即全部事实。**工具 schema 在静态层面不可知**,因此同样的技术会产出一份说谎的文档: - `tool-todo` 写了 `enum: [...STATUSES]`——对一个运行时 `const` 的展开。AST 看到的是展开表达式,而非 `["pending","in_progress","completed"]`。 -- 每段 description 都由字符串**拼接**构建(`'…' + '…'`)。AST 看到的是拼接节点,而非模型实际读到的最终文本。 -- `tool-subagent` 的工具名是 `config.toolName ?? 'subagent'`——加载时选定,不是字面量。 -- MCP 插件可以通过 `ctx.tools.register()` 直接注册**原始 JSON Schema**,完全不经过 `defineTool`,因此结构化枚举 `defineTool(` 调用点会漏计。 +- 每条 description 都通过字符串**拼接**构建(`'…' + '…'`)。AST 看到的是拼接节点,而非模型实际读到的最终文本。 +- `tool-subagent` 的工具名是 `config.toolName ?? 'subagent'`——加载时选定,并非字面量。 +- MCP 插件可以通过 `ctx.tools.register()` 直接注册**原始 JSON Schema**,完全不经过 `defineTool`,因此结构化枚举 `defineTool(` 调用点会遗漏。 -唯一忠实的真源是插件加载后注册表实际持有的 schema。启动即是[测试策略](../../../testing.md)中「验证世界,而非自我报告」这一原则在文档生成器上的应用:读取已发布的产物,而非对它的重新推导。 +唯一忠实的真源是插件加载后注册表实际持有的 schema。启动是[测试策略](../../../testing.md)中「验证世界,而非自我报告」这一原则在文档生成器上的应用:读取已发布的产物,而非对它的再推导。 -### 恢复「不会静默遗漏」 +### 恢复「不会静默遗漏」的保证 -启动有一项 AST 遍历不具备的代价:没有源码声明集合可供枚举,因此新增的工具包可能被遗忘。一道**完整性守卫**恢复了这一保证——`assertManifestComplete` 对 `packages/` 下所有 `tool-*` 包做 glob,若有任何一个不在生成器的启动 manifest 中则硬报错。新增工具包会导致生成器失败,进而导致 doc-sync 失败,直到该包被注册。这与 Cordis 生成器通过枚举源码免费获得的结构性保证相同,只是为启动式生成器重新实现了一遍。 +启动有一项 AST 遍历不存在的代价:没有源码声明集合可供枚举,新工具包可能被遗忘。一个**完整性守卫**恢复了这项保证——`assertManifestComplete` 对 `packages/` 下所有 `tool-*` 包进行 glob,若有任何一个不在生成器的启动 manifest 中则直接报错。新工具包在注册之前会导致生成器失败,进而导致 doc-sync 失败。这与 Cordis 生成器通过枚举源码免费获得的结构性属性相同,只是为基于启动的生成器重新实现了一遍。 -### 手工维护的启动 manifest 是不可约减的策略 +### 手动维护的启动 manifest 是不可化约的策略 -文件系统负责发现工具包清单,完整性守卫负责拒绝遗漏。`TOOL_PACKAGES` 仍然为每个包持有一份显式的启动配方,因为所需的 seam 实现和配置是**策略**,不是能从目录布局或注入名称安全推断的事实。 +文件系统负责发现工具包清单,完整性守卫负责拒绝遗漏。`TOOL_PACKAGES` 仍然为每个包持有一份显式的启动配方,因为所需的 seam 实现和配置属于策略,不是能从目录布局或注入名称安全推断的事实。 ### 范围 -`packages/*/tool-*` 下已发布的产品级工具包,各以默认配置启动:`dsh-tool-bash`(`bash`、`bash_output`、`bash_kill`)、`dsh-tool-todo`(`todo_write`)、`dsh-tool-subagent`(`subagent`)。`examples/` 下的演示工具(`echo`)被排除,与 Cordis 目录的 packages-only 范围一致——演示工具不属于读者所要查阅的产品接口。 +`packages/*/tool-*` 下已发布的产品工具包,每个以默认配置启动:`dsh-tool-bash`(`bash`、`bash_output`、`bash_kill`)、`dsh-tool-todo`(`todo_write`)、`dsh-tool-subagent`(`subagent`)。`examples/` 下的演示工具(`echo`)被排除,与 Cordis 目录仅覆盖 packages 的范围一致——演示工具不属于读者所查阅的产品接口。 -目录的单位是包,而非每个已配置的工具实例。每个包以默认配置启动一次;加载时的别名(如 `subagent_fork`)会注明,但不枚举每种部署排列。部署清单是一个独立的、无界的接口。 +目录的单位是包,而非每个配置化的工具实例。每个包以默认配置启动一次;加载时的别名(如 `subagent_fork`)会注明,但不枚举所有部署排列。部署清单是一个独立的、无界的接口。 ### 使用普通 `json` 围栏 -schema 块使用 ` ```json `,而非自定义的 `ts` 系围栏。`doc-typecheck` 只提取 `ts*` 围栏,因此 JSON 块对它不可见——无需 `BlockKind` 接线(不同于 Cordis 目录的 `ts cordis-catalog` 围栏,后者必须加入白名单以避免裸签名片段被编译)。 +schema 块使用 ` ```json `,而非自定义的 `ts` 系围栏。`doc-typecheck` 只提取 `ts*` 围栏,因此 JSON 块对它不可见——无需 `BlockKind` 接线(不同于 Cordis 目录的 `ts cordis-catalog` 围栏,后者需要加入白名单以避免裸签名片段被编译)。 ## 曾考虑的替代方案 -- **纯 TypeScript AST 遍历,如 Cordis 目录**:工具 schema 不是静态可知的(见上文核心论点):运行时展开、字符串拼接、配置选定的名称,以及原始 `ctx.tools.register()` 注册,都会让 AST 推导出的文档说谎。 -- **从各包的 inject 推断启动配方**:[发现包清单提案](../../proposed/process/2026-06-20-discover-package-inventory.md)所警告的「过于聪明」的路径;配方保持手写策略,清单由文件系统发现并受完整性守卫保护。 +- **纯 TypeScript AST 遍历,如 Cordis 目录**:工具 schema 在静态层面不可知(见上文核心要点):运行时展开、字符串拼接、配置选定的名称,以及原始 `ctx.tools.register()` 注册,都会让 AST 推导出的文档说谎。 +- **从各包的 inject 推断启动配方**:属于[发现包清单提案](../../proposed/process/2026-06-20-discover-package-inventory.md)所警告的「过度聪明」路径;配方保持为手写策略,清单由文件系统发现并由完整性守卫把关。 - **为 schema 块使用自定义 `ts` 系围栏**:不必要。普通 ` ```json ` 围栏对 `doc-typecheck` 不可见,无需 `BlockKind` 白名单。 ## 后果 -- 目录不会漂移:工具 schema 变更而已提交文件未反映时,`verify-tool-catalog` 在 pre-push 钩子和 CI 中失败。新增 `tool-*` 包未加入 manifest 时,完整性守卫直接报错。 -- 工具描述文本只有一个归属地——源码中 `defineTool` 的 `description`——生成的条目质量完全取决于它,与 Cordis 目录对事件 JSDoc 施加的推动力相同。 -- 生成器导入并执行工作区包(这是仓库中第一个这样做的脚本;其他脚本只读取文本)。它通过根 `tsconfig` 的 `paths` 映射在 `tsx` 下运行,走的是演示和测试所用的同一条未构建源码路径,因此不需要构建步骤。 -- 未来某个工具背后新增能力 seam 时,意味着 manifest 中新增一条配方条目(需要挂载哪些 seam)。这是上文明确指出的手写代价;仅在新增工具包时才需变更。 +- 目录不会漂移:工具 schema 变更而已提交文件未反映,`verify-tool-catalog` 会在 pre-push 钩子和 CI 中失败。新 `tool-*` 包未加入 manifest 则完整性守卫直接报错。 +- 工具描述文本有唯一归属——源码中 `defineTool` 的 `description`——生成的条目质量取决于它,与 Cordis 目录对事件 JSDoc 施加的强制力相同。 +- 生成器导入并执行工作区包(这是仓库中第一个这样做的脚本;其他脚本只读文本)。它通过根 `tsconfig` 的 `paths` 映射在 `tsx` 下运行,使用与演示和测试相同的未构建源码路径,因此不需要构建步骤。 +- 未来某个工具背后新增一个能力 seam,意味着 manifest 中需要新增一条配方条目(声明要挂载哪些 seam)。这正是上文指出的有意为之的手写成本;仅在新增工具包时才需变更。 diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml index 7b522b590b..6b19ed7740 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-03-documentation-graph-atlas.md: d10b57e5114684ab0a2caed66fa84b84959bdf12 -2026-07-03-documentation-graph-atlas.zh.md: 8473bc27994bb33eac61659196acc53b69a16449 +2026-07-03-documentation-graph-atlas.zh.md: 6edcfbdbbc3af5808e60888acad04919ce681396 diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md index 8473bc2799..6edcfbdbbc 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -1,69 +1,69 @@ -# RFC:面向维护者和 SDK 用户的文档关系图索引 - -Status: implemented +# RFC:面向维护者与 SDK 用户的文档关系图索引 [English](2026-07-03-documentation-graph-atlas.md) | 中文 +Status: implemented + ## 问题 -仓库此前已有若干高可信度的文档面,各自覆盖不同维度:[module-graph.md](../../../module-graph.md) 由 package 的 `peerDependencies` 生成;生成的 [Cordis 事件目录](../../../cordis-catalog/events.md)和[服务目录](../../../cordis-catalog/services.md)由 Cordis 的 `Events` 与 `Context` 声明生成;[tool-catalog.md](../../../tool-catalog.md) 通过启动已发布的工具插件生成;[core-data-structures/](../../../core-data-structures/core.md) 使用 `ts type-equiv` 块保持粘贴的类型定义与源码同步。 +仓库已有若干高可信度的文档面,各自覆盖不同维度:[module-graph.md](../../../module-graph.md) 由包(package)的 `peerDependencies` 生成;生成的 [Cordis events](../../../cordis-catalog/events.md) 与 [services](../../../cordis-catalog/services.md) 目录由 Cordis 的 `Events` 和 `Context` 声明生成;[tool-catalog.md](../../../tool-catalog.md) 通过启动已发布的 tool 插件生成;[core-data-structures/](../../../core-data-structures/core.md) 使用 `ts type-equiv` 块保持粘贴的类型定义与源码同步。 -这些参考资料是准确的,但它们大多是目录。维护者仍需自行综合关系:哪些 package 构成一条能力 seam、哪个应用捆绑了具体的主干、哪个事件是持久的而哪个是实时的、钩子或策略插件可以在哪里拦截工作、以及哪个面向模型的工具依赖哪个服务。SDK 用户从另一个角度面临同样的问题:「我想要某种行为,该安装或加载哪个 package?该扩展哪个事件/服务/工具?」 +这些参考文档是准确的,但大多是目录式的。维护者仍需自行综合关系:哪些包构成一个能力 seam、哪个应用组装了具体的主干、哪些事件是持久的而哪些是实时的、钩子或策略插件在哪里可以拦截工作、以及哪个面向模型的工具依赖哪个服务。SDK 用户从另一个角度面临同样的问题:「我想要某种行为,应该安装或加载哪个包?应该扩展哪个事件/服务/工具?」 -钩子子系统使事件的生产者/消费者拓扑与拦截点变得更加重要;文件系统 seam 使能力 seam、策略否决、工具呈现和 SDK 组装路径变得更加重要。如果关系图仅限于一个小的 bash/todo/subagent 表面,它们会立刻陈旧。 +钩子子系统使事件的生产者/消费者拓扑与拦截点变得更加重要;文件系统 seam 使能力 seam、策略否决、工具呈现与 SDK 组装路径变得更加重要。如果关系图的范围仅限于一个小的 bash/todo/subagent 表面,它们会立即陈旧。 ## 决策 -新增生成的关系图文档,索引位于 [docs/graph-atlas.md](../../../graph-atlas.md),由专门的生成器产出,并由 `pnpm run verify-doc-graphs` 及既有的目录新鲜度检查(作为 `doc-sync` 的一环)进行验证。 +新增生成的关系图文档,索引位于 [docs/graph-atlas.md](../../../graph-atlas.md),由专用生成器产出,并通过 `pnpm run verify-doc-graphs` 及既有的目录新鲜度检查(作为 `doc-sync` 的一环)进行验证。 -该索引是既有目录之上的关系层。它不替代精确的参考资料,而是链接到它们并解释各部分如何组合在一起。 +该索引是既有目录之上的关系层。它不取代精确的参考文档,而是链接到它们并解释各部分如何组合在一起。 ### 维护模式 每个关系图页面声明一种维护模式: -- **生成(Generated)**:所有节点和边均从源码发现;如果已提交的产物陈旧,`--check` 失败。 -- **混合生成(Hybrid generated)**:源码发现清单,一份小型 manifest 对不可约的策略进行分类,完整性守卫在发现的条目未被分类时失败。 -- **人工维护(Curated)**:图表解释设计意图、时序或归属;它由生成器输出以保证关系图文档作为一个可重新生成的整体,但内容是有意撰写的。 +- **Generated(生成)**:所有节点和边均从源码发现;如果已提交的产物陈旧,`--check` 失败。 +- **Hybrid generated(混合生成)**:源码发现清单,一个小型 manifest 对不可约的策略进行分类,完整性守卫在发现的条目未被分类时失败。 +- **Curated(人工策划)**:图表解释设计意图、时序或归属;它由生成器输出以使关系图文档保持为可重新生成的整体,但内容是有意撰写的。 -### 首批交付的索引 +### 首批发布的索引 -首批索引链接十个关系面。package 拓扑与工具-package 能力映射位于既有的生成目录中(这些目录已拥有相应事实);其余的专项图表由 `scripts/gen-doc-graphs.ts` 生成。 +首批索引链接十个关系面。包拓扑与工具-包能力映射位于已有的生成目录中(这些目录已拥有相应事实);其余聚焦图表由 `scripts/gen-doc-graphs.ts` 生成。 | 关系图 | 维护模式 | 真源 | |---|---|---| -| [模块依赖图](../../../module-graph.md) | 生成 | `packages/*/*/package.json` 的 peer dependencies 加 package 分组路径 | -| [工具 schema 目录与 package 映射](../../../tool-catalog.md) | 生成 | 启动采集的工具 schema 加工具-package 的服务/副作用元数据 | -| [能力 seam 与核心服务](../../../capability-seams.md) | 混合生成 | Cordis 服务声明加 `gen-doc-graphs.ts` 中的角色 manifest | -| [echo-agent 应用组合](../../../../examples/echo-agent/composition.md) | 混合生成 | `examples/echo-agent/cordis.yml` 插件列表加人工维护的应用/bundle 展开 | -| [coding-agent 应用组合](../../../../examples/coding-agent/composition.md) | 混合生成 | `examples/coding-agent/cordis.yml` 插件列表加人工维护的应用/bundle 展开 | -| [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | 混合生成 | `examples/acp-agent/cordis.yml` 插件列表加人工维护的应用/bundle 展开 | -| [事件生产者/消费者矩阵](../../../event-producer-consumer.md) | 混合生成 | Cordis 事件声明、AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 调用点,以及显式的动态分发覆盖 | -| [agent 轮次与步骤生命周期](../../../agent-lifecycle.md) | 人工维护 | architecture.md 的循环生命周期、Cordis 目录链接与会话事件语义 | -| [工具执行流水线](../../../tool-execution-pipeline.md) | 人工维护 | 工具流水线语义与 `tools/execute` waterfall(瀑布式事件) | -| [ACP 快照回放](../../../../packages/ui/acp/snapshot-replay.md) | 人工维护 | 快照 harness 行为 | +| [模块依赖图](../../../module-graph.md) | generated | `packages/*/*/package.json` 的 peer dependencies 加包分组路径 | +| [工具 schema 目录与包映射](../../../tool-catalog.md) | generated | 启动收集的工具 schema 加工具-包的服务/副作用元数据 | +| [能力 seam 与核心服务](../../../capability-seams.md) | hybrid generated | Cordis 服务声明加 `gen-doc-graphs.ts` 中的角色 manifest | +| [echo-agent 应用组合](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | +| [coding-agent 应用组合](../../../../examples/coding-agent/composition.md) | hybrid generated | `examples/coding-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | +| [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | +| [事件生产者/消费者矩阵](../../../event-producer-consumer.md) | hybrid generated | Cordis 事件声明、AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 调用点,以及显式的动态分发覆盖 | +| [agent 轮次与步骤生命周期](../../../agent-lifecycle.md) | curated | architecture.md 的循环生命周期、Cordis 目录链接与会话事件语义 | +| [工具执行流水线](../../../tool-execution-pipeline.md) | curated | 工具流水线语义与 `tools/execute` waterfall(瀑布式事件) | +| [ACP 快照回放](../../../../packages/ui/acp/snapshot-replay.md) | curated | 快照 harness 行为 | -### 为什么由生成器拥有这些文档 +### 为什么由生成器拥有文档 -package 拓扑留在 `gen-module-graph.ts`,工具-package 能力映射留在 `gen-tool-catalog.ts`,因为这些生成器已经拥有权威事实和新鲜度门禁。`gen-doc-graphs.ts` 拥有其余关系页面和索引。代价是人工维护的图表需要在 TypeScript 字符串块中编辑,而非直接编辑 Markdown。对首批交付而言这是可接受的,因为面向用户的产物仍是纯 Markdown/Mermaid;如果撰写体验比可重新生成更重要,未来可以将人工维护的页面拆分出来。 +包拓扑留在 `gen-module-graph.ts`,工具-包能力映射留在 `gen-tool-catalog.ts`,因为这些生成器已经拥有权威事实和新鲜度门禁。`gen-doc-graphs.ts` 拥有其余关系页面和索引。代价是人工策划的图表需要在 TypeScript 字符串块中编辑,而非直接编辑 Markdown。对于首版来说这是可接受的,因为面向用户的产物仍然是纯 Markdown/Mermaid;未来如果撰写体验比可重新生成更重要,可以将人工策划的页面拆分出去。 ### 完整性守卫 -混合生成的页面在其 manifest 陈旧时必须显式失败: +混合生成的页面在其 manifest 陈旧时必须显式报错: -- 模块图读取每个 package 的 `peerDependencies`,并按 `packages/<group>/<pkg>` 路径对 package 分组。 -- 工具目录通过启动采集已发布的工具,并从同一份 manifest(其完整性守卫已在检查)渲染 package/服务/副作用映射。 -- 能力 seam 图导入 Cordis 服务收集器,断言每个被发现的 harness `ctx.<key>` 都已在 `SERVICE_ROLES` 中分类,且每个已分类的 key 仍然存在。 -- 事件生产者/消费者矩阵标记为混合生成,因为 subagent 生命周期事件有意使用 `ctx.events.dispatch` 实现逐监听器隔离;这些动态边是显式覆盖而非无声遗漏。 -- `verify-mermaid` 用 Mermaid 自身的解析器解析仓库中每个 ` ```mermaid ` 围栏,因此语法错误会在本地和 CI 的 `doc-sync` 中失败,而不是在 GitHub 渲染时才显示为损坏的图表。 +- 模块图读取每个包的 `peerDependencies`,并按 `packages/<group>/<pkg>` 路径对包进行分组。 +- 工具目录通过启动收集已发布的工具,并从同一份 manifest 渲染包/服务/副作用映射(其完整性守卫已在检查该 manifest)。 +- 能力 seam 图导入 Cordis 服务收集器,断言每个发现的 harness `ctx.<key>` 都已在 `SERVICE_ROLES` 中分类,且每个已分类的 key 仍然存在。 +- 事件生产者/消费者矩阵标记为 hybrid,因为 subagent 生命周期事件有意使用 `ctx.events.dispatch` 实现逐监听器隔离;这些动态边是显式覆盖而非无声遗漏。 +- `verify-mermaid` 使用 Mermaid 自身的解析器解析仓库中每个 ` ```mermaid ` 围栏,因此语法错误在本地和 CI 的 `doc-sync` 阶段即被捕获,而非在 GitHub 渲染时才显示为损坏的图表。 ## 曾考虑的替代方案 -已提交的图表使用 Mermaid,因为 GitHub 在 Markdown 中原生渲染它,且不引入新的文档构建依赖;密集的多对多数据(如事件生产者/消费者关系)则使用 Markdown 表格。**PlantUML、托管图表服务和生成的 SVG** 曾被考虑,但在 Mermaid 成为瓶颈之前有意不采用。 +已提交的图表使用 Mermaid,因为 GitHub 在 Markdown 中原生渲染它且不引入新的文档构建依赖;密集的多对多数据(如事件生产者/消费者关系)改用 Markdown 表格。**PlantUML、托管图表服务和生成的 SVG** 曾被考虑,但在 Mermaid 成为瓶颈之前有意不采用。 ## 后果 -- 维护者获得了拓扑、seam、事件流、生命周期、应用组合和快照行为的可视化入口。 -- SDK 用户获得了从用例到 package 组合的路径,而不仅仅是自底向上的 package 参考。 -- `doc-sync` 现在包含 `verify-doc-graphs` 和 `verify-mermaid`,因此关系图漂移和 Mermaid 语法错误与其他文档新鲜度门禁一同被捕获。 +- 维护者获得了拓扑、seam、事件流、生命周期、应用组合与快照行为的可视化入口。 +- SDK 用户获得了从用例到包组合的路径,而非仅有自底向上的包参考。 +- `doc-sync` 现在包含 `verify-doc-graphs` 和 `verify-mermaid`,因此关系图漂移和 Mermaid 语法错误与其他文档新鲜度门禁一起被捕获。 - 未来的文件系统和钩子工作有了承载新复杂度的具体位置:文件系统应扩展能力文档和工具目录,钩子应扩展事件矩阵和工具执行流水线。 diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml index 18408700a9..80b4958372 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-cordis-jsdoc-completeness-gate.md: 44a0ddce9c929deac3e03bb421aec1d5145e65ba -2026-07-04-cordis-jsdoc-completeness-gate.zh.md: 6fea7f96a62295bd37e778a0aadd1e9ee6c8f2b4 +2026-07-04-cordis-jsdoc-completeness-gate.zh.md: 073054072748bf6cfed09cdcc222087bcc0ed929 diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md index 6fea7f96a6..0730540727 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md @@ -1,41 +1,41 @@ -# RFC:Cordis 接口的 JSDoc 完整性门禁 - -Status: implemented +# RFC:针对 Cordis 对外服务接口的 JSDoc 完整性门禁 [English](2026-07-04-cordis-jsdoc-completeness-gate.md) | 中文 +Status: implemented + ## 问题 -生成的 Cordis 目录已强制检查事件的 dispatch 模式,但未检查服务与事件契约的完整性。方法可以缺少描述,参数或返回值可以在跨插件 API 接口上不写文档——而这恰恰是 IDE 引导最重要的地方。 +生成的 Cordis 目录此前强制了事件分发模式,但未强制要求完整的服务与事件契约。方法可以缺少描述,参数或返回值可以在跨插件 API 接口上不写文档——而这恰恰是 IDE 引导最重要的地方。 -AGENTS.md 规则("每个导出都有解释语义的 JSDoc")只能靠评审以行文方式检查;本仓库的既定偏好是将不变式编码为机械门禁。"Cordis 服务函数与事件"这一范围有一个精确的机器定义,只有目录生成器知道:事件是 `declare module 'cordis'` 内 `interface Events` 的成员,服务接口是每个 `interface Context` 键所指向的类的公开方法。ESLint 规则看不到这层映射;生成器在每次运行时都会计算它。 +AGENTS.md 中的规则(「每个导出都有解释语义的 JSDoc」)只能靠评审以行文形式检查;本仓库的既定偏好是将不变式编码为机械门禁。「Cordis 服务函数与事件」这一范围有精确的机器定义,只有目录生成器知道:事件是 `declare module 'cordis'` 内 `interface Events` 的成员,服务接口是每个 `interface Context` 键所指向的类的公开方法。ESLint 规则看不到这层映射;生成器在每次运行时计算它。 ## 决策 -扩展 `scripts/gen-cordis-catalog.ts`——同一次遍历、同一个 `@mode` 先例——对其目录化的所有内容强制 JSDoc 完整性。`verify-cordis-catalog` 运行在 `doc-sync` 内部,CI 和 lefthook pre-push 钩子都已执行 `doc-sync`,因此门禁无需新增任何接线(质量门禁原则:单一真源)。 +扩展 `scripts/gen-cordis-catalog.ts`(同一次遍历、同一个 `@mode` 先例),对其编目的所有内容强制 JSDoc 完整性。`verify-cordis-catalog` 在 `doc-sync`(文档同步门禁)内运行,CI 和 lefthook pre-push 钩子都已执行 `doc-sync`,因此门禁无需新增任何接线(质量门禁原则:单一真源)。 契约如下: -- **事件**需要描述文字,并为每个**载荷参数**提供非空 `@param`。载荷参数是签名中承载事件数据的参数;`this` 接收者注解和尾部的 waterfall `next` 免检——`next` 是 dispatch 机制,其语义已由 `@mode waterfall` 标签(及其结构交叉检查)拥有,逐事件重述只是样板。对免检参数写文档是允许的;门禁只检查缺失。 -- **服务类**需要类级 JSDoc,每个公开方法需要描述文字、每个参数一个非空 `@param`,以及一个非空 `@returns`(除非标注的返回类型是 `void`/`Promise<void>`,此时 `@returns` 可选——解析时机可能值得记录——但从不强制要求)。 +- **事件**需要描述性文字,以及为每个**载荷参数**提供非空的 `@param`。载荷参数是携带事件数据的签名参数;`this` 接收者注解和尾部的 waterfall(瀑布式事件) `next` 免检——`next` 是分发机制,其语义已由 `@mode waterfall` 标签(及其结构交叉检查)拥有,逐事件重述只是样板代码。为免检参数写文档是允许的;只有缺失才被检查。 +- **服务类**需要类级 JSDoc,每个公开方法需要描述性文字、为每个参数提供非空的 `@param`,以及非空的 `@returns`——除非标注的返回类型是 `void`/`Promise<void>`(此时 `@returns` 可选——resolve 时机有时值得记录——但从不强制要求)。 - **陈旧标签报错**:`@param` 命名了一个不存在的参数即为违规,与 `@mode` 与签名矛盾的检查对称。标签描述必须非空;超出此范围的语义质量由评审负责。 -- **遍历可检查的显式性**:门禁是纯 AST 遍历(不使用类型检查器),因此服务方法必须显式标注返回类型(推断的返回类型无法分类),接口参数必须是简单标识符(解构模式没有名字供 `@param` 匹配)。 -- **违规聚合**为一条错误信息,列出所有违规项——修复时一次看到全部。此前快速失败的 `@mode` 检查也移入同一份聚合报告,消息文本不变。 +- **遍历可检查的显式性**:门禁是纯 AST 遍历(不使用类型检查器),因此服务方法必须显式标注返回类型(推断的返回类型无法分类),接口参数必须是简单标识符(解构模式没有名称供 `@param` 匹配)。 +- **违规聚合**为一条错误信息,列出所有违规项——修复时一次看到完整清单。此前快速失败的 `@mode` 检查也移入同一份聚合报告,消息文本不变。 -这些标签**仅用于门禁强制**:`parseJsDoc` 现在在遇到第一个块标签时截止描述文字(标准 JSDoc 语义,同时也防止多行标签描述泄漏到目录中成为正文),因此 `@param`/`@returns` 永远不会改变渲染出的目录。 +这些标签**仅用于强制检查**:`parseJsDoc` 现在在遇到第一个块标签时截止描述性文字(标准 JSDoc 语义,同时也防止多行标签描述泄漏到目录中充当正文),因此 `@param`/`@returns` 不会改变渲染出的目录。 -`packages/core/agent/tests/gen-cordis-catalog.spec.ts` 中的负向路径测试用合成 fixture(测试前置数据)驱动 `collectEvents`/`collectServices`,证明每个守卫都能触发且免检规则成立。撰写规则写在根 [AGENTS.md](../../../../AGENTS.md) 约定条目中,与 `@mode` 规则并列。 +`packages/core/agent/tests/gen-cordis-catalog.spec.ts` 中的负路径测试对合成 fixture(测试前置数据)运行 `collectEvents`/`collectServices`,验证每条守卫都会触发且免检规则成立。撰写规则写在根 [AGENTS.md](../../../../AGENTS.md) 的约定条目中,与 `@mode` 规则并列。 ## 曾考虑的替代方案 -- **ESLint 规则**:看不到范围的机器定义(哪些 `interface Events` 成员、哪些 `ctx.<key>` 类构成 Cordis 接口);目录生成器在每次运行时恰好计算这层映射,因此门禁放在那里。 -- **将标签渲染到目录中**:曾考虑将服务部分重构为逐方法条目,但有意推迟:方法文档的消费场景是源码 JSDoc 加 IDE 悬浮提示,目录保持索引定位。 -- **逃生标签**:不设。接口面小且经过策划(采纳时 12 个服务、57 个方法、27 个事件),重点在于检查不可豁免。 +- **ESLint 规则**:无法看到该范围的机器定义(哪些 `interface Events` 成员、哪些 `ctx.<key>` 类构成 Cordis 对外服务接口);目录生成器在每次运行时恰好计算这层映射,因此门禁放在那里。 +- **将标签渲染到目录中**:曾考虑将服务部分重构为逐方法条目,但有意推迟:方法文档的消费场景是源码 JSDoc 加 IDE 悬停,目录保持索引定位。 +- **逃逸标签**:不设。该接口面小且经过策展(采纳时 12 个服务、57 个方法、27 个事件),要点在于检查不可豁免。 ## 后果 -- 新增事件或服务方法如果参数或返回值未写文档,就无法合入:生成器拒绝重新生成,`verify-cordis-catalog` 在 pre-push 和 CI 中失败。采纳时发现的约 139 处缺口在同一个变更中补齐,门禁以绿色状态落地。 -- 服务接口必须显式标注返回类型并使用标识符参数。两项约束在采纳时均未构成负担(所有方法已有标注;不存在解构的 seam 参数);二者现在都是承重要求,违反时会被机械发现。 -- AGENTS.md 的通用 JSDoc 规则("一行能说清就写一行")在此接口上获得一条更严格的特例:只有当方法无参数且返回 void 时,一行摘要才仍然足够。 -- 对 `next` 或 `this` 写 `@param` 合法但不检查——这是有意的不对称:门禁强制载荷契约,拒绝索要样板。 -- 标签不改变渲染出的目录(正文在第一个块标签处截止)。如果日后需要方法级渲染,那是目录设计的独立决策,不是本门禁的缺口。 +- 新增事件或服务方法时,若参数或返回值未写文档则无法落地:生成器拒绝重新生成,`verify-cordis-catalog` 在 pre-push 和 CI 中失败。采纳时发现的约 139 处缺口在同一个变更中补齐,门禁以绿色状态落地。 +- 服务接口必须显式标注返回类型并使用标识符参数。两项约束在采纳时均未构成限制(所有方法已有标注;不存在解构的 seam 参数);但二者现在是承重要求,违反时会被机械检测到。 +- AGENTS.md 中通用的 JSDoc 规则(「一行能说清就用一行」)在此接口上获得了更严格的特例:仅当方法无参数且返回 void 时,一行摘要才足够。 +- 为 `next` 或 `this` 写 `@param` 合法但不检查——这是有意的不对称:门禁强制载荷契约,拒绝要求样板代码。 +- 渲染出的目录不受这些标签影响(正文在第一个块标签处截止)。如果后续需要方法级渲染,那是一个独立的目录设计决策,而非本门禁的缺口。 diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 5d1764cea6..d6465ad8c1 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-doc-tiers-and-budgets.md: ca9da847849f61f2fb244932e657cca8fec69696 -2026-07-04-doc-tiers-and-budgets.zh.md: 37447d11de181a007bcea23e29084aefd97b1ab5 +2026-07-04-doc-tiers-and-budgets.zh.md: ae34e3f04d7986f78d1b3ceeaacb3bc4c7bc64f5 diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index 37447d11de..ae34e3f04d 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -1,28 +1,28 @@ # RFC:文档分层、预算与上限门禁 -Status: implemented - [English](2026-07-04-doc-tiers-and-budgets.md) | 中文 +Status: implemented + ## 问题 -尽管已有写作指导,常设文档仍然积累了重复的规则、重述的事件、重复的 package 地图和陈旧的 RFC 摘要。由于仅靠评审无法阻止这种膨胀,仓库需要在文档分类体系之外再加一道机械化的预算。 +尽管已有写作指导,常设文档仍然积累了重复的规则、重述的事故、重复的包(package)映射和陈旧的 RFC 摘要。仅靠评审无法阻止这种膨胀,因此仓库需要在文档分类体系之外增加一道机械化的预算约束。 ## 决策 -- **分层分类体系,每条事实只有一个归属。** [docs/AGENTS.md](../../../AGENTS.md) 是文档标准:它为每个 Markdown 层级指定唯一职责(常设指令、系统地图、类型目录、决策记录、事件故事、实操手册(cookbook)、package 契约、生成目录、工作流),禁止在归属层级之外重述事实(应改为链接),并附带一份在撰写或评审任何文档时使用的冗余检查清单。 -- **窄范围、硬约束的预算门禁。** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 加入 doc-sync:凡列入 [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 的文档都必须低于其字数上限(`wc -w` 语义,整个文件),且已设预算的文件若缺失也会使门禁失败,防止重命名时预算被静默遗留。范围有意仅限于容易膨胀的常设文档:根目录与子树的 `AGENTS.md`、`architecture.md`、`packages/README.md`,以及它们将内容分流到的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、RFC 和 package README 不设预算:当每一行都是事实时,长度是合理的,由评审加冗余检查清单管控。 -- **上限是只进不退的执行红线。** 上限设定在文档当前大小的至少 5% 以上(留出操作余量,使日常措辞修改不会触发门禁,而真正的膨胀仍会被拦截),并随着文档被压缩到目标预算(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)而保持该余量向下收紧——与[翻译配对 `required` 清单](2026-07-02-bilingual-docs-and-pairing-gate.md)的推进机制相同。门禁变红时,修复方式是按分类体系迁移或精简内容;只有在 PR 描述中给出明确理由时才允许提高上限,manifest diff 本身即为可评审的动作。 -- **轻量工作流 skill,契约在文档中。** [.agents/skills/dsh-doc-standards](../../../../.agents/skills/dsh-doc-standards/SKILL.md) 承载归位/审计/红灯修复工作流,并将文档标准作为真源——与 [dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md) 对 i18n 契约的分工方式相同。 +- **分层分类体系,每条事实只有一个归属地。** [docs/AGENTS.md](../../../AGENTS.md) 是文档标准:它为每个 Markdown 层级指定唯一职责(常设指令、系统地图、类型目录、决策记录、事故叙事、实操手册、逐包契约、生成目录、工作流),禁止在归属层级之外重述事实(应以链接代替),并附带一份在撰写或评审任何文档时使用的冗余检查清单。 +- **窄范围、硬约束的预算门禁。** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 加入 `doc-sync`(文档同步门禁):[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 中列出的每篇文档都必须低于其字数上限(`wc -w` 语义,整个文件),且受预算约束的文件如果缺失也会导致门禁失败,防止重命名后预算被静默遗留。范围刻意限定为容易膨胀的常设文档:根目录和子树的 `AGENTS.md`、`architecture.md`、`packages/README.md`,以及它们将内容分流到的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、RFC 和 package README 不设预算:当每一行都是事实时,长度是合理的,由评审加冗余检查清单来管控。 +- **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。推进机制与[翻译配对的 `required` 清单](2026-07-02-bilingual-docs-and-pairing-gate.md)相同。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。 +- **轻量工作流 skill(技能),契约在文档中。** [.agents/skills/dsh-doc-standards](../../../../.agents/skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯修复工作流,并将文档标准作为真源,与 [dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md) 对 i18n 契约的分工方式一致。 ## 曾考虑的替代方案 -- **仅靠 skill 与评审纪律,不设门禁**:否决。上述膨胀正是在既有的现状规则和评审者注意力下发生的;一条没有机械后盾的行文规则在这里已被证明守不住,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.md)说的是:值得保持的不变式就值得编码。 -- **对所有文档层级设置宽泛门禁**:否决。一刀切的上限恰恰惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实,例如 `packages/ui/acp/acp-feature-support.md`),并产生逐文件的例外修改,训练贡献者无脑批准上调。 -- **将标准放在 skill 内部**:否决。契约放在文档中,工作流放在 skill 中;如果标准被塞进 SKILL.md,那些不调用该 skill 而直接编辑文档的 agent 就看不到它,而 `docs/AGENTS.md` 已经作为子树指令被加载给所有在 `docs/` 下工作的人。 +- **仅靠 skill 和评审纪律,不设门禁**:否决。上述膨胀正是在现行规则和评审注意力已经存在的情况下发生的;一条没有机械后盾的行文规则在此处已被证明无法维持,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.md)认为值得保持的不变式就值得编码。 +- **对所有文档层级全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如特性矩阵或类型目录,每一行都是事实,例如 `packages/ui/acp/acp-feature-support.md`),并产生逐文件的例外变更,训练贡献者机械地批准提限。 +- **将标准放在 skill 内部**:否决。契约归文档,工作流归 skill;如果标准被塞进 SKILL.md,那些不调用该 skill 而直接编辑文档的 agent(智能体)就看不到它,而 `docs/AGENTS.md` 已经作为子树指令被任何在 `docs/` 下工作的人加载。 ## 后果 -- 向已设预算的文档添加内容现在需要置换:将新增内容迁移到其分类归属处并留下指针,或精简既有行文为其腾出空间。只增不减会导致 CI 失败。 -- 将文档压缩到目标预算的重写以堆叠的后续 PR 落地,每个合并时都将 manifest 中的上限向下收紧;在各自落地之前,文档的冻结上限仅阻止进一步膨胀。 -- 字数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它恰好在内容被添加的那一刻强制触发迁移决策——而那正是作者拥有足够上下文来正确归位内容的时刻。 +- 向受预算约束的文档添加内容现在需要置换:将新增内容迁移到其分类体系归属地并留下指针,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。 +- 精简到目标预算的重写以堆叠的后续 PR 落地,每次合并时同步下调 manifest 中的上限;在各自落地之前,文档冻结的上限仅阻止进一步膨胀。 +- 字数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。 diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml index 814ca38546..8d410ae5f8 100644 --- a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-generate-rfc-index-tables.md: 6a8888eda8b7cf7105a44802774bf49d6463952d -2026-07-04-generate-rfc-index-tables.zh.md: 42ae64306aa1d5cf9117ca2b4d698a5d8effdeec +2026-07-04-generate-rfc-index-tables.zh.md: aad1652edc9de81a70a7a391700f63be9499035c diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md index 42ae64306a..aad1652edc 100644 --- a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md @@ -1,34 +1,34 @@ # RFC:生成 RFC 索引表 -Status: implemented - [English](2026-07-04-generate-rfc-index-tables.md) | 中文 +Status: implemented + ## 问题 -RFC 索引中按生命周期/按类别的表格所列信息完全可推导:RFC 的路径编码了生命周期与类别,文件名编码了首次提出日期,H1 标题即为标题。手工维护这些事实的副本恰恰是本仓库文档中冲突最频繁的热点:每一波提案都向同几行追加行,因此并行的 RFC 分支恰好在此处冲突,而在其他所有地方都没有分歧;每次冲突都要手动合并那些文件系统本已知晓内容的行。[分类 RFC](2026-06-20-rfc-classification.md) 最初为了策展目的保留手写索引,但 README 中真正需要策展的部分是行文,而行文从不冲突;冲突的只有机械表格。 +RFC 索引中按生命周期/按分类的表格所列信息完全可以推导:RFC 的路径编码了生命周期与分类,文件名编码了首次提出日期,H1 标题承载了标题文本。这些信息的手工维护副本也是仓库中冲突最频繁的文档热点:每一波提案都在同几行后追加新行,因此并发的 RFC 分支恰好在此处冲突,而其他地方完全一致;每次冲突都要手工合并那些文件系统本已知晓的行。[分类 RFC](2026-06-20-rfc-classification.md) 最初为了可策展性而保留手写索引,但 README 中真正需要策展的是行文,而行文从不冲突;冲突的只有机械表格。 ## 决策 -保留策展行文;生成列表。表格位于 [`docs/rfc/INDEX.md`](../../INDEX.md),是一个**完全生成的文件**;策展行文留在 README.md 中,README.md 不包含任何索引行。[`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) 是共享的真源:树遍历器(拥有封闭的生命周期/类别集合与结构规则,包括 H1 可解析的要求)和渲染器(行来自 H1 标题并去除 `RFC: ` 前缀,加上文件名日期,按日期再按文件名排序,以 `### {Class}` 分节、按规范类别顺序分组)。两个轻量消费方共享它: +保留策展行文;生成列表。表格位于 [`docs/rfc/INDEX.md`](../../INDEX.md),是一个**完全生成的文件**——策展行文留在 README.md 中,README.md 不包含任何索引行。[`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) 是共享的真源:树遍历器(拥有封闭的生命周期/分类集合与结构规则,包括对可解析 H1 的要求)和渲染器(行来自 H1 标题并去掉 `RFC: ` 前缀,加上文件名日期,按日期再按文件名排序,以 `### {Class}` 分节、按规范分类顺序分组)。两个轻量消费方共享它: - [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts)(`pnpm run gen-rfc-index`)从目录树完整重写 INDEX.md。 -- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts)(doc-sync 的一个成员)检查结构,断言已提交的 INDEX.md 与新鲜渲染结果逐字节一致(与 `gen-cordis-catalog`/`verify-cordis-catalog` 模式相同),并拒绝在策展 README 中出现索引格式的行。新鲜度检查涵盖了索引完整性检查:从磁盘生成的表格在定义上就是完整且标题正确的。 +- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts)(doc-sync(文档同步门禁)的一个成员)检查结构,断言已提交的 INDEX.md 与新鲜渲染结果逐字节一致(`gen-cordis-catalog`/`verify-cordis-catalog` 模式),并拒绝在策展 README 中出现索引格式的行。新鲜度检查涵盖了索引完整性检查:从磁盘生成的表格在定义上就是完整的、标题正确的。 -添加、移动或删除一个 RFC 只需编辑 RFC 文件本身并运行生成器;分类 RFC 的「否决替代方案」记录中带有取代关系的交叉链接。 +添加、移动或删除一个 RFC 只需编辑 RFC 文件本身并运行生成器;分类 RFC 的「已否决替代方案」记录中带有替代关系的交叉链接。 ## 曾考虑的替代方案 ### 为什么不在 README.md 内使用标记分隔区域? -最初落地的形态:生成器在 README.md 中的 `gen-rfc-index` 标记注释之间、每个 `## {Lifecycle}` 标题下拼接表格。在 README 同时吸收了文件内格式契约([统一格式 RFC](2026-07-05-uniform-rfc-format.md))之后,被整文件 INDEX.md 方案取代:一个门面 README 承载数百行生成行,会淹没其策展行文;而拼接机制(标记对、标题检查、区域外行检测)的存在只是为了保护策展文本——专用的生成文件根本不包含策展文本。 +最初落地的形态是:生成器将表格拼接到 README.md 中 `gen-rfc-index` 标记注释之间、各 `## {Lifecycle}` 标题之下。在 README 同时吸收了文件内格式契约([统一格式 RFC](2026-07-05-uniform-rfc-format.md))之后,被整文件 INDEX.md 方案取代:一个门面 README 承载数百行生成内容会淹没其策展行文,而拼接机制(标记对、标题检查、区域外行检测)的存在仅仅是为了保护策展文本——专用的生成文件根本不包含这类文本。 -### 为什么不采用纯校验模式? +### 为什么不采用纯校验器模式? -纯校验能捕获错误,但每次提案编辑仍然要在手工维护的表格中触碰共享热点;对于一行纯机械内容,校验失败比生成器更令人烦恼:作者已经命名并放置了文件,索引副本不增加任何信息。这与 [package-inventory 提案](../../proposed/process/2026-06-20-discover-package-inventory.md) 对 tsconfig references 和 knip stanzas 所做的「手工列表 vs. 推导」判断相同——应用于这张确实会冲突的列表。 +校验器能捕获错误,但每次提案编辑仍然要在手工维护的表格中触碰共享热点;对于纯机械的行,校验器失败比生成器更令人烦恼:作者已经命名并放置了文件,索引副本不增加任何信息。这与 [package-inventory 提案](../../proposed/process/2026-06-20-discover-package-inventory.md) 对 tsconfig references 和 knip stanzas 所做的手写列表与推导之间的判断一致——应用于这张确实会冲突的列表。 ## 后果 - 生成文件是显式的:其横幅标注了生成器名称,文件内没有需要保护的策展区域,且生成器在目录树结构无效时拒绝运行。 -- 格式错误或缺失的 H1 在生成器和门禁中都是硬错误:H1 现在是承重的,它是索引标题的来源。 -- 并行的 RFC 分支通过重新运行生成器来解决索引冲突,而非手动合并行。 +- 格式错误或缺失的 H1 在生成器和门禁中都是硬错误——H1 现在是索引标题的承重来源。 +- 并发的 RFC 分支通过重新运行生成器解决索引冲突,从不手工合并行。 diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml index d824b18b5d..5851ab8d1c 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-persistence-log-catalog.md: f8f831ca0470cf3b5c7634550115e67f7deac840 -2026-07-04-persistence-log-catalog.zh.md: d8cd5f74e24968fa2ad01f128dafe8c867f3406c +2026-07-04-persistence-log-catalog.zh.md: 1daa76e2b23484ad6434f6a55482672abf456eb7 diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md index d8cd5f74e2..1daa76e2b2 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md @@ -6,31 +6,31 @@ Status: implemented ## 问题 -`SessionEventMap` 是磁盘上的词汇(vocabulary),但其声明分散在所属的 session 包与声明合并中。生成式持久化目录是每个事件及其 payload 的唯一参考;手工维护的表格会漂移,已被移除。这些记录不是 Cordis 事件:观察者通过唯一的 `session/event` 总线事件接收它们,因此 Cordis 目录无法覆盖。生成器会发现所有声明,doc-sync(文档同步门禁)新鲜度门禁会拒绝遗漏或陈旧的输出。 +`SessionEventMap` 是磁盘上的词汇,但其声明分散在拥有它的 session 包(package)与声明合并中。生成式持久化目录是所有事件与 payload 的唯一参考;手工维护的表格会漂移,已被移除。这些记录不是 Cordis 事件,观察者通过单一的 `session/event` 总线事件接收它们,因此 Cordis 目录无法覆盖。生成器发现所有声明,doc-sync(文档同步门禁)的新鲜度门禁拒绝遗漏或陈旧的输出。 ## 决策 -从源码生成 `docs/persistence-catalog.md`,配以新鲜度门禁,作为第四个参考面:持久化会话日志可以包含的*记录*,与 Cordis 目录(接线)、核心数据结构(词汇)和工具目录(工具)互补。 +从源码生成 `docs/persistence-catalog.md`,配合新鲜度门禁,作为第四个参考面:持久化会话日志可以包含的*记录*,与 Cordis 目录(接线)、核心数据结构(词汇)和工具目录(工具)互补。 -`gen-persistence-catalog.ts` 使用 TypeScript AST 扫描所有所属的和声明合并的 `SessionEventMap`。它渲染源码 JSDoc、payload 类型、派生的 surface 徽章、参考链接和源码位置。doc-sync 新鲜度检查会拒绝任何词汇变更后未重新生成目录的情况。 +`gen-persistence-catalog.ts` 使用 TypeScript AST 扫描所有拥有方与声明合并的 `SessionEventMap`。它渲染源码 JSDoc、payload 类型、派生的 surface 徽章、参考链接与源码位置。doc-sync 新鲜度检查会拒绝任何词汇变更后未重新生成目录的情况。 具体选择: -- **JSDoc 完整性,强制执行。** 每个成员必须携带描述性文字:JSDoc 即为目录条目,与 Cordis 目录对总线事件施加的强制函数相同。成员上的 `@mode` 标签是硬错误:dispatch mode 属于 Cordis 总线事件,日志事件没有 mode;该标签会被误读为「此事件以 mode X 在总线上触发」。违规项聚合为一条错误,列出所有违规者。 -- **surface 徽章由派生得出,而非手工列举。** `SurfaceEventType`(产生 LLM 消息且可能携带 `surfaceOp` 的子集)从所属包中的 union 声明解析而来;union 成员如果命名了一个未声明的事件,则为硬错误(否则一个陈旧的 union 成员会静默地不标注任何事件)。其余一律渲染为 **log-only**。 -- **专用围栏。** payload 块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 识别并跳过它,不计入 opt-out 比例——与 `ts cordis-catalog` 的处理方式相同(裸 payload 片段不能独立编译)。 -- **仓库范围。** 目录枚举本仓库中的包,与兄弟目录的 packages-only 范围一致;下游插件可以合并更多事件类型,但它们在设计上不在目录范围内。遍历过程用硬错误保护自身假设:所属的顶层 `interface SessionEventMap` 必须是 `@deepseek-ai/dsh-session` 中唯一的导出声明(一个无关的、局部的或重复的同名接口不能被当作磁盘词汇编入目录);任何声明不得携带 `extends`(继承的键会加入 `keyof SessionEventMap` 却没有对应的目录行);每个成员必须是带有显式 payload 类型的属性签名(方法形式的成员会加入 `keyof` 却被静默遍历跳过);跨声明的重复成员会失败。 +- **JSDoc 完整性,强制执行。** 每个成员必须带有描述性文字——JSDoc 即为目录条目,与 Cordis 目录对总线事件施加的强制机制相同。成员上的 `@mode` 标签是硬错误:dispatch mode 属于 Cordis 总线事件,日志事件没有 mode,该标签会被误读为「此事件以模式 X 在总线上触发」。违规项聚合为一条错误,列出所有违规者。 +- **surface 徽章由派生得出,而非手工列举。** `SurfaceEventType`(产生 LLM(大语言模型)消息且可能携带 `surfaceOp` 的子集)从拥有方包中的 union 声明解析;如果 union 成员命名了一个未声明的事件,则为硬错误(否则陈旧的 union 成员会静默地不标注任何内容)。其余一律渲染为 **log-only**。 +- **专用围栏。** payload 块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 识别并跳过它,不计入 opt-out 比例——与 `ts cordis-catalog` 的处理方式相同(裸 payload 片段无法独立编译)。 +- **仓库范围。** 目录枚举本仓库中的包,与兄弟文档的 packages-only 范围一致;下游插件可以合并更多事件类型,它们在设计上不在目录范围内。遍历过程用硬错误保护自身假设:拥有方的顶层 `interface SessionEventMap` 必须是 `@deepseek-ai/dsh-session` 中唯一的导出声明(无关的、局部的或同名重复的接口不能被当作磁盘词汇编入目录);任何声明不得携带 `extends`(继承的键会加入 `keyof SessionEventMap` 却没有对应的目录行);每个成员必须是带有显式 payload 类型的属性签名(方法形式的成员会加入 `keyof` 却在静默遍历中被漏过);跨声明的重复成员也会失败。 -这取代了手工副本:session.md 的 `hook/*` 表格、compact README 的事件表格、hook-protocol README 的 payload 列表,以及 session README 的名称列表现在链接到目录,而非重述 payload(周围的语义行文保留原位)。hook-protocol 合并成员上两个多余的 `@mode emit` 标签已被移除——新门禁将其拒绝为它们本来就是的类别错误。 +本方案取代了手工副本:session.md 的 `hook/*` 表格、精简版 README 的事件表格、hook-protocol README 的 payload 条目列表,以及 session README 的名称列表现在链接到目录,而不再重述 payload(周围的语义说明文字保留原位)。hook-protocol 合并成员上的两个误加的 `@mode emit` 标签已被移除——新门禁将它们作为类别错误拒绝。 ## 曾考虑的替代方案 -- **基于启动的生成器(如工具目录的方式)**:日志词汇完全是静态的,AST 遍历无需启动任何东西即可读取全部真相。 -- **保留手工副本**:手工副本只能检查作者已经写下的名称;目录落地时 session README 的合并说明已经漂移。 +- **基于启动的生成器(类似工具目录)**:日志词汇完全是静态的,AST 遍历无需启动任何东西即可读取全部真相。 +- **保留手工副本**:手工副本只能检查作者已经写下的名称;目录落地时,session README 的合并说明已经漂移。 ## 后果 -- 目录不可能漂移:词汇变更而已提交文件未反映的,`verify-persistence-catalog` 在 pre-push 钩子和 CI 中会失败;新合并的事件如果没有 JSDoc,生成器直接报错——插件不能再添加未文档化的磁盘记录类型。 -- 事件描述有唯一归属地:声明处的 JSDoc。JSDoc 写得薄,目录条目就薄,这对作者形成在源头写文档的压力。 +- 目录不会漂移:词汇变更若未反映在已提交的文件中,`verify-persistence-catalog` 会在 pre-push 钩子和 CI 中失败;新合并的事件若缺少 JSDoc,生成器直接报错——插件不再能添加未文档化的磁盘记录类型。 +- 事件描述有唯一归属地,即声明处的 JSDoc;JSDoc 写得单薄,目录条目就单薄,这迫使作者在源头做好文档。 - `SurfaceEventType` union 现在对文档具有结构性承载作用:重命名事件而不更新 union(或反过来)会导致生成器失败,而不仅仅是编译器失败。 -- 徽章派生假设 union 始终是一组封闭的字符串字面量且只有一个所有者;如果重构偏离了这一形状,必须在同一个变更中更新生成器。 +- 徽章派生假设 union 始终是一组封闭的字符串字面量且只有一个拥有方;如果重构偏离了这一形状,必须在同一个变更中更新生成器。 diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml index bb326da2d8..a5e22d0911 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-uniform-rfc-format.md: f67c61c0b627950cf07e7d57672324308a9462ec -2026-07-05-uniform-rfc-format.zh.md: a11cba1c343f150a67c1af4a04a8d89480185207 +2026-07-05-uniform-rfc-format.zh.md: b175c2d5b7537e793a61c95b6524d08bc4216384 diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md index a11cba1c34..b175c2d5b7 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md @@ -1,30 +1,30 @@ -# RFC:为 RFC 统一一种有门禁保障的文件内格式 - -Status: implemented +# RFC:RFC 的统一受门禁约束的文件内格式 [English](2026-07-05-uniform-rfc-format.md) | 中文 +Status: implemented + ## 问题 -RFC 的路径已编码了生命周期与分类,但文件内容仍然混杂着不同的标题风格、状态格式、ADR 模板与提案模板,以及已实施记录中残留的提案时期章节。作者复制手边找到的任何邻居文件作为模板,而生命周期迁移可以跳过必要的改写,因为没有门禁强制执行文件内契约。 +RFC 的路径已经编码了生命周期和分类,但文件内容仍然混杂着不同的标题风格、状态格式、ADR 与 proposal 模板,以及已实现记录中残留的 proposal 时期的章节。作者随手复制找到的任何邻近文件,生命周期迁移时可以跳过必要的改写,因为没有门禁强制执行文件内契约。 ## 决策 -[README.md § The file format](../../README.md#the-file-format) 即为文件内契约:头部块(`# RFC: <title>` 加上不含日期、与所在文件夹一致的 `Status:` 枚举,其唯一内容是否决原因);按生命周期区分的正文骨架(所有阶段都以 `Problem` 开头;`proposed/` 中使用 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 中使用现在时的 `Decision`/`Consequences` 且禁止提案时期标题;`rejected/` 中冻结提案形态);强制的 `Alternatives considered` 章节;以及规范的章节词汇表——在这些固定章节之间,自定义的技术章节保持自由格式。`pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts))作为 doc-sync(文档同步门禁)的一环强制执行每一条机械化条款,因此跳过改写的生命周期迁移现在会导致 CI 失败,而非依赖评审者的记忆。 +[README.md § The file format](../../README.md#the-file-format) 即文件内契约:头部块(`# RFC: <title>` 加上无日期、与所在文件夹一致的 `Status:` 枚举,唯一的正文内容是 rejection reason);按生命周期区分的正文骨架(所有阶段都以 `Problem` 开头;`proposed/` 中为 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 中为现在时态的 `Decision`/`Consequences` 且禁止 proposal 时期的标题;`rejected/` 中冻结 proposal 形态);必须包含 `Alternatives considered` 章节;以及规范的章节词汇表,其间的自定义技术章节保持自由形式。`pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts))作为 doc-sync(文档同步门禁)的一环强制执行每条机械化条款,因此生命周期迁移时跳过改写现在会让 CI 失败,而不是依赖评审者的记忆。 -整个语料库在定义格式的同一个变更中完成了规范化——这是预发布阶段的立场:不设过渡期,不容忍双格式并存。唯一的祖父条款针对内容而非格式:替代方案只记录已有的,不凭空编造;因此如果一篇格式定义之前的 RFC 的替代方案无法从记录中重建,它会携带 `rfc-format: alternatives-not-recorded` 注释,门禁仅对日期早于本 RFC 的文件接受该注释。 +整个语料库在定义格式的同一个变更中完成了规范化,这是预发布阶段的立场:没有过渡期,不容忍双格式并存。唯一的祖父条款针对内容而非格式:替代方案是记录下来的,不是凭空编造的;因此如果一篇格式定义前的 RFC 的替代方案无法从记录中重建,它会携带 `rfc-format: alternatives-not-recorded` 这条精确注释,门禁仅对日期早于本 RFC 的文件接受该注释。 ## 曾考虑的替代方案 -- **完全刚性模板**(每个生命周期一套固定章节顺序,所有 RFC 重构以适配):否决。大型设计 RFC 携带八到十五个自定义技术章节(包拓扑、协议格式契约、schema),这些是承重内容而非漂移;刚性顺序会迫使当下进行破坏性改写,并永远与模板对抗。 -- **仅规范化头部**(H1 与 Status,正文不动):否决。技术债标记指出的正是*正文*的体裁分裂,让 `Context`/`Decision` 与 `Problem`/`Proposal` 无限期并存什么也解决不了。 -- **不设 Status 行**(文件夹本身就是状态;三篇最新的格式定义前 RFC(及其中一篇的中文对侧文件)省略了该行):否决,保留自描述文件。当初促使去掉该行的漂移风险,已被「门禁将该行与文件夹做一致性校验」所消除。 -- **带日期的状态**(`Status: implemented (accepted YYYY-MM-DD)`):否决。接受日期属于叙述性历史,写作规则将其排除在文档之外;文件名承载首次提出日期,git 承载其余信息,门禁能检查日期格式但永远无法检查其真实性。 -- **裸 `# <title>` H1**:否决。`RFC: ` 前缀是语料库中的多数形式,且在文件脱离目录树阅读时能自描述体裁;索引生成器会剥离它,因此索引行无论哪种写法都一样。 -- **`## What we give up` 作为已实施记录的收尾章节**(README 自身用来描述 RFC 所记录内容的措辞):否决。它只命名了代价,而诚实的后果章节同时记录权衡所换来的收益。 -- **约定而无门禁**(写下契约,靠评审强制执行):否决。slop checklist 已通过约定禁止在 `implemented/` 中使用规范体措辞,而十九个文件展示了纯靠约定在这里能达到什么效果。 -- **独立的 `FORMAT.md` 契约文件**:最初落在此处;在生成索引迁出至 [INDEX.md](../../INDEX.md) 后折入 README.md:表格移走后 README 重新有了空间,一个前门同时承载布局、分类与格式,优于将契约拆分到两个文件。 +- **完全刚性的模板**(每个生命周期一个固定章节序列,所有 RFC 重构以适配):否决。大型设计 RFC 包含八到十五个自定义技术章节(包拓扑、协议格式契约、schema),这些是承重内容而非漂移;刚性序列会立即强制破坏性改写,并永远带来与模板的对抗。 +- **仅规范化头部**(H1 和 Status,正文不动):否决。债务标记指出的是*正文*的体裁分裂,让 `Context`/`Decision` 与 `Problem`/`Proposal` 无限期并存什么也解决不了。 +- **不设 Status 行**(文件夹本身就是状态;格式定义前最新的三篇 RFC(以及其中一篇的中文对侧文件)省略了该行):否决,保留自描述文件。省略 Status 行的动机是防止漂移,而将该行与文件夹做门禁校验即可消除漂移风险。 +- **带日期的 Status**(`Status: implemented (accepted YYYY-MM-DD)`):否决。接受日期属于叙述性历史,写作规则将其排除在文档之外;文件名承载首次提出日期,git 承载其余信息;门禁能检查日期格式,但永远无法检查其真实性。 +- **裸 `# <title>` H1**:否决。`RFC: ` 前缀是语料库中的多数形式,且在文件脱离目录树被阅读时能自描述体裁;索引生成器会剥离前缀,因此索引行无论哪种写法都一样。 +- **`## What we give up` 作为 implemented 的结尾章节**(README 自身对 RFC 记录内容的措辞):否决。它只命名了代价,而诚实的后果章节同样记录这笔权衡换来了什么。 +- **只有约定没有门禁**(写下契约,靠评审强制执行):否决。slop checklist 已经通过约定禁止在 `implemented/` 中使用 spec 语气,而十九个文件展示了仅靠约定在此处能达到什么效果。 +- **独立的 `FORMAT.md` 契约文件**:最初的落地位置;在生成索引迁出到 [INDEX.md](../../INDEX.md) 之后折入 README.md:表格移走后 README 重新有了空间,一个前门同时承载布局、分类和格式,优于将契约拆分到两个文件。 ## 后果 -每篇 RFC 现在多了少许结构成本,而强制的 `Alternatives considered` 章节是有意为之的摩擦:一个不记录被否决方案的决策,会招来 RFC 本应防止的反复讨论。格式定义前的 RFC 若其替代方案无法重建,则永久携带祖父条款注释——这是记录上的诚实空白,而非编造的理由。doc-sync 新增一道门禁,在生命周期文件夹之间迁移 RFC 现在是迁移时的实际工作(即迁移本就欠下的正文改写),而非无人追踪的延后清理。三十九个技术债标记已全部消除,由它们等待的模板所解决。 +每篇 RFC 现在需要略多一些结构,而必须包含 `Alternatives considered` 章节是刻意的摩擦:一个没有记录被否决方案的决策,会招致 RFC 本来就是为了防止的重新争论。格式定义前的 RFC 如果替代方案无法重建,则永久携带祖父条款注释,这是记录上的诚实缺口,而非编造的理由。`doc-sync` 增加一道门禁,将 RFC 在生命周期文件夹之间迁移现在是迁移时的实际工作(即迁移本就欠下的正文改写),而非无人追踪的延后清理。三十九个债务标记已全部消除,由它们等待的模板所解决。 diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml index dd08d60974..b79ff4cf97 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-export-surface-jsdoc-gate.md: fd255cc6212d7f6f919ad23f999fa68b01478a73 -2026-07-06-export-surface-jsdoc-gate.zh.md: 02a5c2682095c66818a51cc14fd565111b7e6e80 +2026-07-06-export-surface-jsdoc-gate.zh.md: 796b5bb8f3a2a890986c73511bb63e167c831a5f diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md index 02a5c26820..796b5bb8f3 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md @@ -1,45 +1,45 @@ # RFC:导出表面 JSDoc 门禁 -Status: implemented - [English](2026-07-06-export-surface-jsdoc-gate.md) | 中文 +Status: implemented + ## 问题 -[cordis JSDoc 完整性门禁](2026-07-04-cordis-jsdoc-completeness-gate.md)使 cordis 表面上的未文档化参数和返回值不再可能——`interface Events` 成员与 `ctx.<key>` 服务类——但那只是插件作者所导入内容的一小部分。AGENTS.md 中「每个导出(及非显而易见的方法)都应有 JSDoc 说明语义」这条规则在其他地方仍然只能靠评审以行文方式检查,而且没有任何机制要求普通导出函数带 `@param`/`@returns`。采纳时的一次调查发现 34 个包中有 203 个文档不完整的模块级导出:seam 相关辅助函数(`runBash`、`readForEdit`、`htmlToMarkdown`)、格式编解码器、整个未文档化的接口和类型别名——正是 IDE 消费方悬停时看到的那些名字。 +[Cordis JSDoc 完整性门禁](2026-07-04-cordis-jsdoc-completeness-gate.md)使得 Cordis 表面上的参数和返回值不可能缺少文档——`interface Events` 成员和 `ctx.<key>` 服务类——但这只是插件作者所导入内容的一小部分。AGENTS.md 中的规则「每个导出(以及非显而易见的方法)都必须有解释语义的 JSDoc」在其他地方只能靠评审以行文方式检查,而且没有任何机制要求普通导出函数带 `@param`/`@returns`。采纳时的一次调查发现 34 个包(package)中有 203 个文档不完整的模块级导出:seam 相关辅助函数(`runBash`、`readForEdit`、`htmlToMarkdown`)、格式编解码器、完全无文档的接口和类型别名——恰恰是 IDE 消费方悬停查看的那些名称。 ## 决策 -新增门禁 `scripts/verify-export-jsdoc.ts`(`pnpm run verify-export-jsdoc`,接入 doc-sync,与 `verify-cordis-catalog` 并列),遍历每个 `packages/<group>/<pkg>/src/` 目录树下所有模块级导出名。解析与检查辅助函数从 `gen-cordis-catalog.ts` 移入共享的 `scripts/jsdoc.ts`,因此「已文档化」在两个表面上含义一致:描述文本在第一个块标签处截止,每个可检查参数需要非空 `@param`,非 void 的**已标注**返回值需要非空 `@returns`,过时的 `@param` 报错,违规汇总为一份报告。 +新增门禁 `scripts/verify-export-jsdoc.ts`(`pnpm run verify-export-jsdoc`,接入 `doc-sync`(文档同步门禁),与 `verify-cordis-catalog` 并列),遍历每个 `packages/<group>/<pkg>/src/` 目录树下的所有模块级导出名称。解析与检查辅助函数从 `gen-cordis-catalog.ts` 移入共享的 `scripts/jsdoc.ts`,使得「已文档化」在两个表面上含义一致:描述性文字在第一个块标签处截止、每个可检查参数需要非空 `@param`、非 void 且有显式标注的返回值需要非空 `@returns`、过时的 `@param` 报错,违规项汇总为一份报告。 按声明类型划分的契约: -- 每个导出名都需要带有非空描述文本的 JSDoc。 -- 函数类导出(函数声明;以函数初始化器或行内可调用标注的 const;非标识符的函数默认导出)遵循完整的函数契约,分类前会剥离包装表达式(括号、`as`/`satisfies` 转型、非空断言)。如果 const 的声明器标注了一个**命名**类型(`export const f: Handler = …`),则签名契约推迟到该类型自身的声明,`@returns` 可选;行内 `(x: T) => U` 标注或单调用签名字面量即为表面签名本身,适用完整契约;而字面量中混合了调用/构造签名与其他成员的情况则直接拒绝(没有单一签名可供标签对照——请提取命名类型)。 -- 导出类需要类级别的描述文本;公开方法(包括静态方法——可通过导出名访问)遵循函数契约;公开属性和访问器需要描述文本(get/set 对由 getter 覆盖)。重载实现免检——由签名承载文档。 -- 导出的接口、类型别名和枚举需要声明级别的描述文本;成员级别的强制有意推迟(承载关键成员契约的 seam 服务类已在 cordis 门禁下)。 -- 导出的命名空间递归检查(在 ambient `declare` 命名空间内,每个成员隐式导出);命名空间本身仅在不与同名已文档化声明合并时才需要描述文本(Config 命名空间惯用法只需文档化插件一次)。 -- `declare module` / `declare global` 体和 `export … from` 再导出语句被跳过:augmentation 不是包的导出,再导出的定义在其定义处检查。`export import X = N.member` 别名文档化**自身**——其目标可能是遍历不会访问的非导出命名空间成员——且仅支持纯描述文本的目标类型:可调用、类或命名空间目标携带别名描述文本无法承载的签名/成员契约,门禁拒绝此类情况并要求直接导出该声明。 -- 其余一切按**封闭**原则失败:`export =` 直接拒绝;基类从未命名的参数即使作为绑定模式仍保留 `@param` 义务;调度未识别的导出语句类型本身即为违规——没有任何导出形式能因遗漏而免检。 +- 每个导出名称都需要带有非空描述文字的 JSDoc。 +- 函数类导出(函数声明;初始化器为函数或带有内联可调用标注的 const;非标识符的函数默认导出)遵循完整的函数契约,分类前会剥离包装表达式(括号、`as`/`satisfies` 类型断言、非空断言)。如果 const 声明器标注了一个具名类型(`export const f: Handler = …`),签名契约推迟到该类型自身的声明处,`@returns` 保持可选;内联的 `(x: T) => U` 标注或单调用签名字面量本身就是表面签名,适用完整契约;而混合了调用/构造签名与其他成员的字面量则直接拒绝(没有单一签名可供标签对照——请提取具名类型)。 +- 导出类需要类级别的描述文字;公开方法(包括静态方法——可通过导出名称访问)遵循函数契约;公开属性和访问器需要描述文字(get/set 对由 getter 覆盖)。重载实现体免检——签名承载文档。 +- 导出接口、类型别名和枚举需要声明级别的描述文字;成员级别的强制有意推迟(承载关键成员契约的 seam 服务类已在 Cordis 门禁之下)。 +- 导出命名空间递归检查(在 ambient `declare` 命名空间内,每个成员隐式导出);命名空间本身仅在不与同名的已文档化声明合并时才需要描述文字(Config-namespace 惯用法只需文档化插件一次)。 +- `declare module`/`declare global` 体和 `export … from` 重导出语句被跳过:augmentation 不是包的导出,重导出的定义在其定义处检查。`export import X = N.member` 别名需要文档化**自身**——其目标可能是遍历不会访问的非导出命名空间成员——且门禁仅支持纯描述文字的目标类型:可调用、类或命名空间目标携带别名描述文字无法承载的签名/成员契约,门禁会拒绝并要求直接导出该声明。 +- 其余情况按封闭原则失败:`export =` 直接拒绝;基类从未命名的参数即使作为绑定模式仍需 `@param`;dispatch 不识别的导出语句类型本身就是违规——没有任何导出形式能因遗漏而免检。 -三类豁免避免门禁要求样板代码,精神与 cordis 门禁的 `this`/`next` 豁免一致(对已豁免的名字主动写文档是允许的;只有缺失才不被检查): +三类豁免避免门禁要求样板代码,精神与 Cordis 门禁的 `this`/`next` 豁免一致(为已豁免的名称编写文档是允许的;只有缺失才不被检查): -- **继承成员。**重写从基类声明继承文档。新增的公开表面仍需文档:新增参数、将 protected 成员公开重写、或在 void 基类之上给出具体返回值。继承查找与推断返回值分类是门禁唯一需要类型检查器的工作;其他检查使用 AST。 -- **插件协议槽位。**顶层 `name` / `inject` / `reusable` / `Config` const 与 `apply` 入口,以及插件类上作为静态成员的相同槽位,属于框架协议:其形状由 cordis 固定,模块文档注释加 `interface Config` 承载插件的真实语义。 -- **构造函数**,与 cordis 门禁一致:插件类由框架构造,类文档承载全部说明。 +- **继承成员。** 重写从其基类声明继承文档。新增的公开表面仍需文档:新增参数、将 protected 成员公开重写、或在 void 基类之上返回具体类型。继承查找和推断返回值分类是门禁唯一需要类型检查器的工作;其他检查使用 AST。 +- **插件协议槽位。** 顶层的 `name`/`inject`/`reusable`/`Config` 常量和 `apply` 入口,以及插件类上的同名静态成员,属于框架协议:其形状由 Cordis 固定,模块文档注释加 `interface Config` 承载插件的真实语义。 +- **构造函数**,与 Cordis 门禁一致:插件类由框架构造,类文档承载全部说明。 -`collectExportJsdocViolations()` 返回违规列表(CLI 在非空时以 exit 1 退出),因此 `packages/core/agent/tests/verify-export-jsdoc.spec.ts` 中的负路径测试直接对发现结果断言,通过 fixture 包驱动每一种拒绝和每一种豁免。 +`collectExportJsdocViolations()` 返回违规列表(CLI 在非空时以 1 退出),因此 `packages/core/agent/tests/verify-export-jsdoc.spec.ts` 中的负路径测试直接断言发现项,通过 fixture(测试前置数据)包驱动每一种拒绝和每一种豁免。 ## 曾考虑的替代方案 -- **eslint-plugin-jsdoc**(`require-jsdoc`/`require-param`/`require-returns`):覆盖了机械核心,但无法表达本仓库的契约:继承成员豁免需要跨包类型解析,协议槽位和命名空间合并惯用法是 cordis 特有的,而完整性语义(标签前描述文本、过时标签报错、聚合报告)已在 `scripts/jsdoc.ts` 中与 catalog 生成器共享一处。两套微妙不同的「已文档化」定义正是本仓库「一处为家」规则要防止的失败模式。 -- **扩展 `gen-cordis-catalog.ts`**:catalog 生成器渲染一个精选表面并门禁其新鲜度(freshness);仓库级遍历没有 catalog 可渲染。共享辅助函数但保持遍历分离,使每个门禁的职责清晰可读。 -- **强制接口/类型别名的成员文档**:推迟。这会将检查表面扩大到大量自描述字段,而承载关键成员契约的 seam 类已在门禁下。如果评审中出现成员文档漂移再重新考虑。 +- **eslint-plugin-jsdoc**(`require-jsdoc`/`require-param`/`require-returns`):覆盖了机械核心,但无法表达本仓库的契约。继承成员豁免需要跨包的类型解析,协议槽位和命名空间合并惯用法是 Cordis 特有的,而完整性语义(标签前描述文字、过时标签报错、汇总报告)已在 `scripts/jsdoc.ts` 中与 catalog 生成器共享。两套微妙不同的「已文档化」定义,正是本仓库「单一归属」规则所要防止的失败模式。 +- **扩展 `gen-cordis-catalog.ts`**:catalog 生成器渲染一个精选表面并守卫其新鲜度;仓库级遍历没有 catalog 可渲染。共享辅助函数、保持遍历独立,使每个门禁的职责清晰可读。 +- **强制接口/类型别名的成员文档**:推迟。这会使检查表面成倍增长,而这些成员大多是自描述的字段;承载关键成员契约的 seam 服务类已有门禁。如果评审中出现成员文档漂移再重新考虑。 ## 后果 -- 新导出不能在无文档的情况下落地:`verify-export-jsdoc` 使 doc-sync 失败,而 pre-push 和 CI 已经运行 doc-sync。采纳时发现的 203 处缺口在同一个变更中补齐,因此门禁以绿色状态落地。 -- 导出函数必须标注返回类型(采纳时已全面覆盖,现在成为承载性要求),且在 `@param` 需要命名的地方使用标识符参数。 -- seam 文档是权威的:实现从继承链继承文档,值得保留在实现上的行为说明是补充,而非必需。 -- 门禁构建一个 `ts.Program`(约 6 秒)——唯一需要类型解析的文档门禁;在已经编译文档片段的 doc-sync 中可以接受。 -- 协议槽位名在模块顶层按约定保留;一个碰巧名为 `apply` 或 `Config` 的非协议导出会免检——已接受,记录于此。 +- 新增导出不能在无文档的情况下合入:`verify-export-jsdoc` 使 `doc-sync` 失败,而 pre-push 和 CI 已运行 `doc-sync`。采纳时发现的 203 处缺口在同一个变更中补齐,门禁以绿色状态落地。 +- 导出函数必须标注返回类型(采纳时已全面满足,现在成为门禁依赖),并在 `@param` 需要命名参数时使用标识符参数。 +- seam 文档是权威的:实现从其继承链继承文档,值得保留在实现上的行为说明是补充,而非必需。 +- 门禁构建一个 `ts.Program`(约 6 秒)——唯一需要类型解析的文档门禁;在已编译文档片段的 `doc-sync` 内可以接受。 +- 协议槽位名称按约定保留在模块顶层;一个恰好命名为 `apply` 或 `Config` 的非协议导出将不被检查——已接受,记录于此。 diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml index 394c2494ac..ccfbb1cc49 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-generated-config-catalog.md: 50ba0dc70b0d8ea54a4f93911f3a087806774626 -2026-07-06-generated-config-catalog.zh.md: 2dd0cbe5303f08aa2f3300c6f8613a7823c2bc78 +2026-07-06-generated-config-catalog.zh.md: 87a861bab394ec268fb3c870e848db37fa4d6fcf diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md index 2dd0cbe530..87a861bab3 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md @@ -6,35 +6,35 @@ Status: implemented ## 问题 -仓库此前没有以源码为后盾的插件配置参考。各 package 的 README 对字段的记录方式不一致,没有列举哪些包可被加载,也没有校验运行时 schema 是否与声明的配置类型一致。 +仓库此前没有以源码为后盾的插件配置参考。各 package 的 README 对字段的记录方式不一致,未列举哪些包可被加载,也未校验运行时 schema 与声明的配置类型是否一致。 ## 决策 -`scripts/gen-config-catalog.ts` 从每个插件声明的配置类型与 JSDoc 生成 [docs/config-catalog.md](../../../config-catalog.md),包含注入要求、引用类型链接和源码指针。package 内部类型被传递性地包含;workspace 和外部类型以链接或名称引用。确定性的 `--write` 和 `--check` 模式使提交到仓库的页面成为一个生成产物(artifact)。 +`scripts/gen-config-catalog.ts` 从每个插件声明的配置类型与 JSDoc 生成 [docs/config-catalog.md](../../../config-catalog.md),包含注入要求、引用类型链接和源码指针。包内局部类型被传递性地包含;workspace 和外部类型以链接或名称形式引用。确定性的 `--write` 和 `--check` 模式使提交到仓库的页面成为一个生成产物。 -此处采用纯 AST 生成是正确的,原因与事件/服务目录相同,也与工具目录不同:配置类型是静态声明,仓库中每个 schemastery schema 都是静态的 `z.object`/`z.intersect` 字面量,因此源码就是全部真相——配置表面没有任何部分是运行时组合的。 +此处采用纯 AST 生成是正确的,原因与 events/services catalog 相同,而与 tool catalog 不同:配置类型是静态声明,仓库中每个 schemastery schema 都是静态的 `z.object`/`z.intersect` 字面量,因此源码即全部真相——配置表面没有任何部分是运行时组合的。 具体选择: -- **配置类型取自第二参数类型。** 目录记录的是 `apply(ctx, config)` / 服务构造函数 `(ctx, config)` 的声明参数类型——即 Cordis 实际传入的值——而非按命名约定定位的 `Config` 导出。这使得遍历是全量的:无论接口名为 `AcpConfig` 还是 `BasicCompactConfig`,无论类型声明在兄弟文件中,还是插件根本没有校验 schema,都能正常工作。 -- **分类是全量的。** 每个 `packages/<group>/<pkg>` 条目都会被解析(镜像 Loader 的 `unwrapExports`(`exports.default ?? exports`)),归入以下之一:可配置插件、无配置插件、抽象 seam 类或库——各自渲染在独立章节中——无法归类的条目会硬错误。新 package 不可能被静默地遗漏。 -- **逐字段 JSDoc 强制要求。** 粘贴的声明中每个属性(包括嵌套的类型字面量)都需要非空的 JSDoc 描述,否则生成失败。粘贴本身就是文档,因此这与事件目录通过 `@mode` 施加的强制函数相同:源码文档不足时门禁失败,而非产出一份单薄的目录。 -- **Schema 键与声明类型交叉检查。** 生成器通过本地和 workspace 类型解析嵌套的对象与数组路径。确定缺失的路径会失败;无法枚举的外部或动态形状则跳过。检查有意设计为单向的,因为声明类型可能包含从 loader 配置中排除的运行时专用字段。 +- **配置类型是第二参数的类型。** catalog 记录的是 `apply(ctx, config)` / 服务构造函数 `(ctx, config)` 的声明参数类型——即 Cordis 实际传入的值——而非按命名约定定位的 `Config` 导出。这使得遍历是全量的:无论接口叫 `AcpConfig` 还是 `BasicCompactConfig`,无论类型声明在兄弟文件中,还是插件完全没有验证 schema,都能正常工作。 +- **分类是全量的。** 每个 `packages/<group>/<pkg>` 条目都会被解析(镜像 Loader 的 `unwrapExports`:`exports.default ?? exports`),归入可配置插件、无配置插件、抽象 seam 类或库之一——各自渲染在独立小节中——无法归类的条目直接报错。新 package 不可能被悄悄遗漏。 +- **逐字段 JSDoc 强制要求。** 粘贴的声明中每个属性(包括嵌套的类型字面量)都需要非空的 JSDoc 描述,否则生成失败。粘贴本身就是文档,因此这与 events catalog 通过 `@mode` 施加的强制函数相同:源码文档过于单薄时门禁报错,而非产出单薄的 catalog。 +- **Schema 键与声明类型做比对。** 生成器通过局部和 workspace 类型解析嵌套的对象与数组路径。确定缺失的路径报错;无法枚举的外部或动态形状则跳过。比对有意设计为单向的,因为声明类型可能包含被排除在 loader 配置之外的运行时专用字段。 - **专用围栏。** 粘贴的声明使用 ` ```ts config-catalog ` 信息字符串,`doc-typecheck` 会跳过它(引用了导入类型的孤立声明无法独立编译),并将其排除在 opt-out 比例之外——与 `cordis-catalog` 和 `persistence-catalog` 围栏的处理方式相同。 -- **单文件 `docs/config-catalog.md`**,而非一个单文件目录:该页面服务于单一受众(`cordis.yml` 的编写者),只有一个维度,不同于 `cordis-catalog/`(它包含两个并列页面)。 +- **单文件 `docs/config-catalog.md`**,而非一个单文件目录:该页面面向单一受众(`cordis.yml` 的编写者),只有一个维度,不同于 `cordis-catalog/`(其中包含两个并列页面)。 -各 package README 的 `## Config` 章节保留。这种重叠是有意接受的:README 是精心策划的逐 package 契约(部署上下文中的配置语义,连同限制与扩展点),目录则是穷举式的生成枚举。因为目录是生成的,二者之间的分歧说明 README 有误,修复方式是编辑 README——目录不会漂移。 +各 package README 中的 `## Config` 小节保留。重叠是有意接受的:README 是经过策划的逐包契约(在部署上下文中描述配置语义,连同限制与扩展点),catalog 则是穷举式的生成枚举。由于 catalog 是生成的,二者不一致时说明 README 有误,修复方式是编辑 README——catalog 不会漂移。 ## 曾考虑的替代方案 -- **合成式逐字段渲染**:为每个字段生成一个项目符号列表、表格或带注释的 YAML 片段,由解析后的 JSDoc 加 schema 元数据组装。否决,改用逐字粘贴:带 JSDoc 的接口本身就是以其原始形式撰写的契约,合成渲染器会重新格式化它不拥有的行文,增加一层可能歪曲原意的渲染。 -- **运行时启动 + schema 内省(如工具目录的做法)**:否决。此处没有任何内容是运行时组合的,而且 schema 本身对配置表面的文档化不足(行文记录的默认值、运行时专用字段、完全没有 schema 的插件)。启动只会增加脆弱性而不增加真相。 -- **双向 schema/接口相等性检查**:否决,改用子集检查。声明类型合理地包含 schema 拒绝从配置接受的成员(运行时专用的 seam)。 -- **在同一变更中废弃 README 的 `## Config` 章节**:否决。接受的重叠使逐 package 契约在原地可读,而一次清扫需要先把每个 README 的额外事实折入字段 JSDoc——这是可分离的工作,目录不依赖它。 +- **合成式逐字段渲染**:为每个字段生成项目符号列表、表格或带注释的 YAML 片段,从解析的 JSDoc 加 schema 元数据组装。否决,改用逐字粘贴:接口连同其 JSDoc 本身就是以原始形式撰写的契约,合成渲染器会重新格式化它不拥有的行文,增加一个可能歪曲原意的渲染层。 +- **运行时启动 + schema 内省(如 tool catalog 所做的那样)**:否决。此处没有任何内容是运行时组合的,且 schema 本身对配置表面的文档化不足(以行文记录的默认值、运行时专用字段、完全没有 schema 的插件)。启动只会增加脆弱性而不增加真相。 +- **双向 schema/接口等价检查**:否决,改用子集检查。声明类型合理地包含 schema 拒绝从配置接受的成员(运行时专用 seam)。 +- **在同一变更中废除 README `## Config` 小节**:否决。保留可接受的重叠使逐包契约在原处可读,而清理工作需要先把每个 README 的额外事实折入字段 JSDoc——这是可分离的工作,catalog 不依赖它。 ## 后果 -- 目录不会漂移:源码变化而提交的文件未反映时,`verify-config-catalog` 在 pre-push 和 CI 中失败。未记录的配置字段、无法解析的引用类型名称、或 schema 键在配置类型中缺失,都会导致生成器直接报错。 -- 配置行文现在在声明处有了强制函数:编写新的配置字段意味着编写其 JSDoc,而 JSDoc 会逐字成为目录条目。 -- 生成器对无法静态遍历的形状硬错误——别名化的 package 内部配置导入、非 `object`/`intersect` 组合构建的 schema、未列入的全局类型名。引入这样的形状就必须同时教会生成器(否则该形状不能进入仓库),这正是设计意图:目录始终是全部真相。 -- `gen-cordis-catalog.ts` 导出其 JSDoc/指针辅助函数与 `LINK_MAP` 供复用,因此两个目录以相同方式交叉链接类型,新增一条 link-map 条目同时服务于两者。 +- catalog 不会漂移:源码变更而提交的文件未反映时,`verify-config-catalog` 在 pre-push 和 CI 中报错。未文档化的配置字段、无法解析的引用类型名、或 schema 键在配置类型中缺失,都会直接导致生成器报错。 +- 配置行文现在有了声明处的强制函数:编写新配置字段意味着编写其 JSDoc,而该 JSDoc 将逐字成为 catalog 条目。 +- 生成器对无法静态遍历的形状直接报错——别名化的包内配置导入、非 `object`/`intersect` 组合构建的 schema、未列入的全局类型名。引入此类形状时必须同时教会生成器(否则该形状不能进入仓库),这正是设计意图:catalog 始终是全部真相。 +- `gen-cordis-catalog.ts` 导出其 JSDoc/指针辅助函数与 `LINK_MAP` 供复用,因此两个 catalog 以相同方式交叉链接类型,新增一条 link-map 条目同时服务于两者。 diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index d7db5cac08..815cf98e72 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-node-engine-floor.md: 561b6b4a124b6eaa8e2ba0756a835e35519b30b8 -2026-07-06-node-engine-floor.zh.md: c6ace7a1296b5e3049ff4ef55f29b689fce7f4ff +2026-07-06-node-engine-floor.zh.md: 21af2da919754b1ae4667b46ef2f65c14a279b49 diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md index c6ace7a129..21af2da919 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -1,39 +1,39 @@ # RFC:将 Node LTS 引擎下限提升至 22.19 -Status: implemented - [English](2026-07-06-node-engine-floor.md) | 中文 +Status: implemented + ## 问题 -根 `engines.node` 范围中的 Node 22 分支是对已安装工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。该分支的下限不得低于工作区在该分支上安装的依赖所声明的 package `engines.node`;否则 `pnpm install --engine-strict` 会在一个被宣传的 LTS 版本上失败,而非严格模式的安装则会在依赖所支持的运行时范围之外运行。 +根 `engines.node` 范围中的 Node 22 分支是对已安装工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。它不得低于工作区在该分支上安装的依赖所声明的 package `engines.node`;否则 `pnpm install --engine-strict` 会在一个已宣传的 LTS 版本上失败,而非严格模式的安装则会在依赖所支持的运行时范围之外运行。 ## 决策 -将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每个矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限同时通过完整的源码类型检查和真实的未构建运行时路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上运行,因为它验证的是 API 集成而非运行时下限。 +将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每条矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限通过完整的源码类型检查和真实的未构建运行时路径两条路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。 -两项 Node 特性决定了源码运行时的下限: +两个 Node 特性决定了源码运行时的门槛: -- **`node:sqlite`**:`packages/session-persistence/session-persistence-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` flag 要求;在此之前,导入它会在加载时抛出异常。 -- **原生 TypeScript 类型剥离**:`packages/examples/stdio-demo/tests/built-bin.e2e.ts` 冒烟测试在纯 `node`(无 tsx)下启动已发布的 `lib/bin.js`,并加载示例的 `.ts` 插件(`mock-llm.ts`、`echo-tool.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;在此之前需要 `--experimental-strip-types`。 +- **`node:sqlite`**:`packages/session-persistence/session-persistence-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` 标志要求;在此之前,导入它会在加载时抛出异常。 +- **原生 TypeScript 类型剥离**:`packages/examples/stdio-demo/tests/built-bin.e2e.ts` 冒烟测试在纯 `node`(不用 tsx)下启动已发布的 `lib/bin.js`,并加载示例的 `.ts` 插件(`mock-llm.ts`、`echo-tool.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;在此之前需要 `--experimental-strip-types`。 -这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步抬高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的 package 声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不连续范围完全排除 Node 23:Node 23.0–23.5 仍有至少一项源码特性需要 flag,而 23 线是非 LTS/已 EOL,宣传 `>=23.6` 只会增加一个已死的发布线和一个不应被任何部署使用的 CI 分支。 +这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的 package 声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 -`@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:如果使用了 Node 23+/24+/25+ 才有的 API,`tsc` 会在所有机器和类型检查门禁中报错,而不是编译通过后存活到只有下限矩阵分支才能捕获的运行时失败。整棵树目前在 Node 22 类型表面上类型检查全部通过,因此这个固定没有代价。 +`@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:使用 Node 23+/24+/25+ 的 API 会在所有机器和类型检查门禁中导致 `tsc` 失败,而不是编译通过、直到仅下限矩阵分支才能捕获的运行时错误才暴露。目前整个代码树在 Node 22 类型表面上类型检查全部通过,因此这一固定没有任何代价。 ## 后果 - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。 -- CI 用 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每个分支都对源码图做类型检查,并实际启动未构建的工作流 worker。 -- built-bin 冒烟测试不需要版本条件 flag:在 22.19 上类型剥离已是默认行为,因此测试保持其文档记录的纯 `node lib/bin.js` 路径。 -- 未来如有依赖或源码 API 抬高运行时下限,必须在同一个变更中同步修改 `engines.node`、兼容性矩阵与本 RFC。 +- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每条分支都对源码图执行类型检查,并实际启动未构建的工作流 worker。 +- built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。 +- 未来如果有依赖或源码 API 提高运行时下限,必须在同一个变更中同步修改 `engines.node`、兼容性矩阵和本 RFC。 ## 曾考虑的替代方案 - **保持 `^22.18.0 || >=24.0.0`。** 否决:它宣传的 LTS 版本低于 Pi 适配器依赖的下限。`@earendil-works/pi-ai@0.79.3` 要求 `>=22.19.0`。 -- **降级或固定 `@earendil-works/pi-ai` 以保留 22.18 的宣传范围。** 否决:当前的 Pi 适配器依赖是工作区的预期组成部分,且 22.19 仍在 Node 22 LTS 线内。 -- **下限设为 `>=22.13`(`node:sqlite` 边界),在 22.13–22.17 的 built-bin 冒烟测试中加 `--experimental-strip-types`。** 否决:为一个窄范围增加版本条件测试 flag,并将对实验性 flag 的依赖伪装成正式支持。Pi 适配器依赖已经要求更高的 LTS 下限。 -- **开放式 `>=22.19`。** 否决:它宣传支持 Node 23.0–23.5,而在这些版本上 `node:sqlite`(直到 23.4)或类型剥离(直到 23.6)仍需 flag。 -- **包含 Node 23.6+(`^22.19.0 || >=23.6.0`)。** 否决:23.6+ 确实能无 flag 运行两项源码特性,但 Node 23 已 end-of-life;宣传一个已死的发布线只会增加一个范围项和一个 CI 分支,用于一个不应被任何部署使用的运行时。 -- **矩阵用 `[22, 24, 26]` 而非固定 `22.19`。** 否决:浮动的主版本号条目会随时间上漂,悄然不再验证所声明的 LTS 下限。 -- **让 `@types/node` 超前于下限(`^25`)。** 否决:类型定义超前于运行时下限会让仅 Node 24/25 才有的 API 编译通过,仅在 22.x 上运行时才失败。将 `@types/node` 固定在 22.x 线上,会把这种情况变成所有环境下的编译错误。 +- **降级或固定 `@earendil-works/pi-ai` 以保留 22.18 的宣传范围。** 否决:当前 Pi 适配器依赖是预期工作区的一部分,且 22.19 仍在 Node 22 LTS 线内。 +- **下限 `>=22.13`(`node:sqlite` 边界)加上在 22.13–22.17 的 built-bin 冒烟测试中使用 `--experimental-strip-types`。** 否决:它为一个狭窄范围增加了版本条件测试标志,并将实验性标志依赖包装为正式支持。Pi 适配器依赖已经要求更高的 LTS 下限。 +- **开放式 `>=22.19`。** 否决:它宣传支持 Node 23.0–23.5,而在这些版本上 `node:sqlite`(直到 23.4)或类型剥离(直到 23.6)仍需标志。 +- **包含 Node 23.6+(`^22.19.0 || >=23.6.0`)。** 否决:23.6+ 确实能无标志运行两个源码特性,但 Node 23 已 end-of-life;宣传一条已终止的发布线会增加一个范围项和一条 CI 分支,而没有任何部署应当使用该运行时。 +- **矩阵 `[22, 24, 26]` 而非固定 `22.19`。** 否决:浮动的主版本号条目会随时间上漂,悄然不再验证所声明的 LTS 下限。 +- **将 `@types/node` 保持在下限之前(`^25`)。** 否决:类型定义超前于运行时下限会让仅 Node 24/25 才有的 API 编译通过,仅在 22.x 上运行时才失败。将 `@types/node` 固定在 22.x 线上可将此类问题转化为所有环境下的编译错误。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml index 81369745d3..7abfb8a8ed 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-parallel-github-ci-gates.md: 890adf58ac8a20a39806aa028d035cb253d5a4f1 -2026-07-06-parallel-github-ci-gates.zh.md: 02502b1005a1f8e6f9f539878c792a9f0585e926 +2026-07-06-parallel-github-ci-gates.zh.md: 47562ed6a83b650a3275b4045c39c4de6d2ecac6 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md index 02502b1005..47562ed6a8 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md @@ -1,41 +1,41 @@ # RFC:并行 GitHub CI 门禁 -Status: implemented - [English](2026-07-06-parallel-github-ci-gates.md) | 中文 +Status: implemented + ## 问题 -keyless GitHub CI 门禁大多彼此正交:类型检查、lint、文档新鲜度、覆盖率、快照回放、构建、包发布卫生检查、demo 冒烟测试和 built-bin 冒烟测试各自因不同原因失败,且不需要彼此的运行时状态。将它们串成一条有序命令链,工作流的挂钟时间等于所有门禁之和;而将每个叶子门禁拆成独立的 GitHub job,则会重复 checkout、Node 搭建、pnpm restore 和 install 工作,直到编排开销本身成为瓶颈。 +keyless GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照回放、构建、包发布卫生检查、demo 冒烟测试与 built-bin 冒烟测试各自因不同原因失败,彼此不需要对方的运行时状态。将它们串成一条有序命令链,工作流的挂钟时间等于所有门禁之和;而把每个叶子门禁拆成独立的 GitHub job,则会重复 checkout、Node 设置、pnpm restore 和 install 工作,直到编排开销本身成为瓶颈。 -难点在于产物边界。`publint`、`verify-node-next-types` 和 built-bin 冒烟测试需要构建出的 `lib/` 输出,而大多数门禁只需要源码和依赖。盲目扇出要么让这些产物消费方在 `pnpm run build` 输出声明文件和 bundle 之前就开始执行,要么在每个依赖产物的 job 中重复构建。 +难点在于产物边界。`publint`、`verify-node-next-types` 和 built-bin 冒烟测试需要构建出的 `lib/` 输出,而大多数门禁只需要源码和依赖。盲目扇出要么让这些产物消费方在 `pnpm run build` 输出声明文件和 bundle 之前就开始竞跑,要么在每个依赖产物的 job 中重复构建。 ## 决策 [CI](../../../../.github/workflows/ci.yml) 将 keyless 检查分组为若干宽粒度的主运行时 lane,外加一个兼容性矩阵。工作流文件拥有当前 lane 和运行时清单的定义权。 -每个 lane 委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),后者以有界并发调度独立门禁,并为每个门禁打印一个可归因的结果块。产物消费方在各自 lane 内依赖一次 build;兼容性 job 则将类型检查与一次真实的未构建 worker 启动相结合,以覆盖运行时特定的 loader 行为。 +每个 lane 委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),该脚本以有界并发调度独立门禁,并为每个门禁打印一个可归因的结果块。产物消费方依赖其所在 lane 内的一次 build,而兼容性 job 将类型检查与一次真实的未构建 worker 启动结合,以覆盖运行时特定的 loader 行为。 -生成的 `.sessions/` 日志和 `.doc-typecheck-*` 临时目录被 lint 忽略。聚合的本地 CI 模式仍在 lint 之后运行 demo 冒烟测试;而拆分后的 GitHub 静态 lane 可以直接运行 demo 冒烟测试,因为 lint 已隔离在自己的 lane 中。 +生成的 `.sessions/` 日志和 `.doc-typecheck-*` 临时目录被 lint 忽略。聚合的本地 CI 模式仍在 lint 之后运行 demo 冒烟测试,而拆分后的 GitHub static lane 可以直接运行 demo 冒烟测试,因为 lint 已隔离在自己的 lane 中。 -构建输出在 Node 24 产物 lane 中只生成一次。产物消费方(`publint`、`verify-node-next-types` 和 built-bin 冒烟测试)声明对 `build` 的依赖,因此没有 upload/download 交接,消费方也不可能抢在声明文件或 bundle 之前执行。CI 覆盖率报告仅为文本格式,本地覆盖率则保留 HTML 报告。 +构建输出在 Node 24 的产物 lane 中只生成一次。产物消费方(`publint`、`verify-node-next-types` 和 built-bin 冒烟测试)声明对 `build` 的依赖,因此没有 upload/download 交接,消费方也不可能在声明文件或 bundle 就绪之前抢跑。CI 覆盖率报告仅输出文本,本地覆盖率则保留 HTML 报告。 -两个工作流都缓存 pnpm store。真实 API 工作流使用共享的有界 Vitest 文件池,而非为每组测试单独开 job。 +两个工作流都缓存 pnpm store。真实 API 工作流使用共享的有界 Vitest 文件池,而非为每组测试单独开一个 job。 ## 曾考虑的替代方案 -- **在 Node 矩阵中保留完整串行链**:最容易理解,但会重复执行不产生 Node 版本特定信号的仓库级门禁,且让每个 PR 等待所有门禁之和。 -- **每个门禁各开一个 GitHub job**:最大化 GitHub 可见的扇出,但产生过多 check,且对运行时间短于 runner 准备时间的门禁反复支付 setup/install 开销。 -- **将构建产物上传给依赖产物的 job**:在多 job 间保持正确性,但增加了 artifact upload/download 时间,且在产物消费方可以通过主 job 内的本地依赖运行时仍保持工作流过宽。 -- **并发运行 `typecheck` 和 `build`**:向调度器暴露更多工作,但两者都调用 `tsc -b`;在它们之间共享增量构建状态是一场不必要的竞争,换来的挂钟收益很小。 -- **使用无界的真实 API e2e 并行度**:否决。该套件包含大量真实模型/工具场景;worker 池需要一个显式的 `DSH_E2E_MAX_WORKERS` 上限,这样 CI 和本地运行都能扇出而不会把配额或资源问题隐藏在不稳定的限流失败背后。 +- **在 Node 矩阵中保留完整串行链**:最容易推理,但会重复执行不产生 Node 版本特定信号的仓库级门禁,且让每个 PR 等待所有门禁的总和。 +- **每个门禁作为独立 GitHub job 运行**:最大化 GitHub 可见的扇出,但产生过多 check,且对运行时间短于 runner 准备时间的门禁而言,重复的 setup/install 开销得不偿失。 +- **将构建产物上传给依赖产物的 job**:在多 job 间保持正确性,但增加了 artifact upload/download 时间,且当产物消费方可以在主 job 内通过本地依赖排序运行时,工作流仍然过宽。 +- **并发运行 `typecheck` 与 `build`**:向调度器暴露更多工作,但两个命令都调用 `tsc -b`;在它们之间共享增量构建状态是一场不必要的竞争,换来的挂钟收益很小。 +- **使用无界的真实 API e2e 并行度**:否决。该套件包含大量真实模型/工具场景;worker 池需要一个显式的 `DSH_E2E_MAX_WORKERS` 上限,使 CI 和本地运行都能扇出,同时不会把配额或资源问题隐藏在不稳定的限流失败背后。 ## 后果 -PR 反馈以少量 GitHub check 呈现,每个宽粒度 job 内部包含结构化的逐门禁日志块。这使 runner setup 开销可控、Actions UI 紧凑,代价是失去了每个叶子门禁各自独立的 status check。 +PR 反馈以少量 GitHub check 的形式呈现,每个宽粒度 job 内部包含结构化的逐门禁日志块。这将 runner 设置开销控制在有限范围内,并保持 Actions UI 紧凑,代价是失去了每个叶子门禁独立的状态标记。 -宽粒度 lane 拆分比单一主 job 更频繁地重复 checkout、setup 和 install。这一 setup 开销是有意为之:在 GitHub 托管 runner 上,将 lint、覆盖率和快照回放放在同一个进程池中运行会严重超额占用 CPU,以至于单 job 的关键路径反而长于重复 setup 的方案。 +宽 lane 拆分比单一主 job 更频繁地重复 checkout、setup 和 install。这一设置开销是有意为之的:在 GitHub 托管 runner 上,将 lint、覆盖率和快照回放放在同一个进程池中运行会严重超额占用 CPU,以至于单 job 的关键路径比重复设置还要长。 -这种拆分引入了一项维护义务:当 `package.json` 增删属于 CI 的门禁时,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 需要相应增删叶子。这一义务是有意的,因为该 runner 是同一套门禁词汇的并行执行计划,而非独立的质量策略。 +这种拆分引入了一项维护义务:当 `package.json` 新增或移除一个应纳入 CI 的门禁时,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 需要添加或删除对应的叶子。这一义务是有意为之的,因为该 runner 是同一套门禁词汇的并行执行计划,而非独立的质量策略。 -兼容性信号窄于主 Node 24 信号。它证明源码图在每个宣称支持的运行时上能通过类型检查、且真实的未构建 workflow-worker 启动路径能正常执行,而不必重复文档、覆盖率、发布卫生、快照回放和其他不因 Node 版本而异的冒烟检查。 +兼容性信号比主 Node 24 信号更窄。它证明源码图在每个声明支持的运行时上都能通过类型检查,且真实的未构建 workflow-worker 启动路径能够执行,而不必重复文档、覆盖率、发布、快照回放以及那些不因 Node 版本而异的无关冒烟测试。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index 3adf939f24..6475a65971 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-parallel-pre-push-gates.md: 8a8813f2f3f6726ab2ab028757406d366ceb8b6d -2026-07-06-parallel-pre-push-gates.zh.md: 36207e522982990c193f9fe909faf380de53bca6 +2026-07-06-parallel-pre-push-gates.zh.md: 6ee3a8003853092d75cda9908bfae13ee0d4c7a2 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 36207e5229..6ee3a80038 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -1,42 +1,42 @@ # RFC:并行 pre-push 门禁 -Status: implemented - [English](2026-07-06-parallel-pre-push-gates.md) | 中文 +Status: implemented + ## 问题 -pre-push 钩子是分支离开本地机器前的最后一道检查点,因此它的挂钟时间直接影响贡献者是否愿意保持启用并信任其信号。Lefthook 已经能并行运行顶层 job,但 `pnpm run hygiene` 和 `pnpm run doc-sync` 这类聚合 job 在单个 job 内部隐藏了长串的顺序执行链。因此钩子可以配置为并行,却仍在等待那些成员彼此独立的串行子命令。 +pre-push 钩子是分支离开本地机器前的最后一道检查点,因此它的挂钟时间直接影响贡献者是否愿意保持启用并信任其信号。Lefthook 已经能并行运行顶层 job,但 `pnpm run hygiene` 和 `pnpm run doc-sync` 等聚合 job 在单个 job 内部隐藏了长串的顺序执行链。钩子因此可能在配置上看似并行,实际仍在等待那些成员彼此独立却串行执行的子命令。 -把这些成员直接展平到 `lefthook.yml` 只能解决本地钩子的问题。CI 有同样的调度问题,而在 YAML 中重复一份长长的叶子列表会让未来的脚本改动有两处可能漂移。 +将这些成员直接展平到 `lefthook.yml` 只能解决本地钩子的问题。CI 面临同样的调度问题,而在 YAML 中复制一长串叶子列表会让未来的脚本改动有两处可能漂移。 -`publint` 在更低一层也有同样的形态。每个包独立地针对自身的 manifest 和构建产物做 lint,但运行器按顺序逐个遍历所有包。在本仓库中,这意味着一个包发布门禁消耗的时间与包数量成正比,尽管各检查之间并不共享可变状态。 +`publint` 在更低一层也有同样的形态。每个包(package)独立地根据自身 manifest(元数据清单)和构建产物做 lint,但 runner 按顺序遍历所有包。在本仓库中,这意味着一个包发布门禁的耗时与包的数量成正比,尽管各检查之间并不共享可变状态。 ## 决策 -[lefthook.yml](../../../../lefthook.yml) 保留一个名为 `full check` 的 pre-push job,运行 `pnpm run check:pre-push`。该包脚本委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),即 CI 使用的同一个有界调度器。 +[lefthook.yml](../../../../lefthook.yml) 保留一个名为 `full check` 的 pre-push job,运行 `pnpm run check:pre-push`。该 package 脚本委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),即 CI 使用的同一个有界调度器。 -`pre-push` 模式展开为以下叶子门禁:单元测试套件、快照测试套件、构建、`hygiene` 成员、`doc-sync` 成员,以及 module-graph 新鲜度。叶子列表保持与包脚本相同的门禁词汇(包括 RFC 分类和 RFC 格式),运行器并发调度独立检查,并为每个门禁打印一个计时/输出块。 +`pre-push` 模式展开为以下叶子门禁:单元测试套件、快照测试套件、构建、`hygiene` 成员、`doc-sync` 成员,以及 module-graph 新鲜度。叶子列表保持与 package 脚本相同的门禁词汇,包括 RFC 分类和 RFC 格式,同时 runner 并发调度独立检查,并为每个门禁打印一个计时/输出块。 -构建门禁使钩子在干净 worktree 上也能自给自足。`publint` 和 `verify-node-next-types` 等待构建产物,而仅依赖源码的门禁继续并行执行。 +构建门禁使钩子在干净的 worktree 上也能自足运行。`publint` 和 `verify-node-next-types` 等待构建产物就绪,而仅依赖源码的门禁继续并行执行。 -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包列表,并使用大小取自 `availableParallelism()` 的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以为资源配置不同的本地机器和 CI runner 设置 worker 数量上限或提高上限。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱每个包的日志块。 +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包列表,并使用大小取自 `availableParallelism()` 的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可为资源配置不同的本地机器和 CI runner 设定或提高 worker 数量上限。结果按包缓冲,并以确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 -聚合包脚本仍然是临时本地运行的真源。调度器是对其成员门禁的并行执行计划,而非替代词汇。 +聚合 package 脚本仍然是临时本地运行的真源。调度器是对其成员门禁的并行执行计划,而非替代词汇。 ## 曾考虑的替代方案 -- **在钩子中保留聚合的 `hygiene` 和 `doc-sync` job**:配置更简单,但 pre-push 的大部分挂钟时间仍然花在 lefthook 看不到也无法调度的串行命令链内部。 -- **为每个叶子门禁声明一个 lefthook job**:通过 lefthook 原生的 job 模型暴露并行性,但会让钩子文件承载一份 CI 无法复用的长成员列表。 -- **要求开发者在推送前手动构建**:省去一个钩子门禁,但会导致 `publint` 在干净 worktree 上失败,并把最后的本地检查点从可运行的检查降格为一项约定。 +- **在钩子中保留聚合的 `hygiene` 和 `doc-sync` job**:配置更简单,但 pre-push 的大部分挂钟时间仍然消耗在 lefthook 看不到也无法调度的串行命令链内部。 +- **为每个叶子门禁声明一个 lefthook job**:通过 lefthook 原生 job 模型暴露并行性,但会让钩子文件承载一长串成员列表,CI 无法复用。 +- **要求开发者在推送前手动构建**:可以省去一个钩子门禁,但会导致 `publint` 在干净 worktree 上失败,并把最后的本地检查点从可运行的检查降级为一种约定。 - **在 shell 脚本中使用后台子命令**:能并行化工作,但会丢失 lefthook 的 job 名称、逐 job 计时和失败分组,且信号处理更难推理。 -- **为每个包声明一个 publint lefthook job**:暴露最大并行度,但会把钩子变成一份手工维护的包清单,恰好在新增包时漂移。 -- **以无界并发运行 publint**:仅在小型机器上以赌进程数、内存压力、包 tarball 创建和日志可读性为代价来最小化耗时。 +- **为每个包声明一个 publint lefthook job**:暴露最大并行度,但会让钩子变成一份手动维护的包清单,恰好在新增包时漂移。 +- **以无界并发运行 publint**:仅在小型机器上以赌注方式最小化耗时,代价是进程数、内存压力、包 tarball 创建和日志可读性的风险。 ## 后果 -钩子的关键路径变为最慢的那个实际门禁,而非隐藏门禁链的总和。Lefthook 报告一个 `full check` job,运行器在该 job 内部报告逐门禁计时,因此本地检查点慢时仍能指出主导耗时的那个门禁。 +钩子的关键路径变为最慢的单个真实门禁,而非隐藏门禁链的总和。Lefthook 报告一个 `full check` job,runner 在该 job 内部报告逐门禁计时,因此本地检查点偏慢时仍能指向主导耗时的那个门禁。 -钩子文件保持简短,重复的成员列表集中在 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中,CI 和 pre-push 可以共享。代价是一个自定义调度器脚本(而非纯 lefthook 配置),外加本地 pre-push 路径中的一次构建。 +钩子文件保持简短,重复的成员列表集中在 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中,CI 和 pre-push 可以共享。代价是引入一个自定义调度器脚本(而非纯 lefthook 配置),外加本地 pre-push 路径中的一次构建。 -`publint-all.ts` 变为异步代码,缓冲命令输出而非实时继承 stdio。收益是包级并行、稳定的输出顺序,以及一个用于资源调优的环境变量。 +`publint-all.ts` 变为异步代码,缓冲命令输出而非实时继承 stdio。收益是包级别的并行性、稳定的输出顺序,以及一个用于资源调优的环境变量。 diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml index bd24f4a6c1..81355776bb 100644 --- a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-10-readme-known-limitations-gate.md: b7f45421bf0d4d50ec1a19941934e782f52e7926 -2026-07-10-readme-known-limitations-gate.zh.md: 4dd8db1df9b0b2be73e7ae6a64e11b8dabc2add1 +2026-07-10-readme-known-limitations-gate.zh.md: dc023ac43890d8aaaefaece2e592001629e2a74e diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md index 4dd8db1df9..dc023ac438 100644 --- a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md @@ -1,29 +1,29 @@ -# RFC:在每个 package README 中设置受门禁保护的「已知限制」章节 - -Status: implemented +# RFC:在每个 package README 中设置受门禁保护的 Known Limitations 章节 [English](2026-07-10-readme-known-limitations-gate.md) | 中文 +Status: implemented + ## 问题 -[文档标准](../../../AGENTS.md)将限制事项归属于 package README。如果没有统一的格式,缺失的章节无法区分「经过审计确认无限制」和「忘记写了」,而各式各样的标题也让仓库级搜索无从下手。 +[文档标准](../../../AGENTS.md)将限制事项指定在 package README 中记录。如果没有统一的格式,缺失的章节无法区分「经审计确认无此内容」与「忘了写文档」,而标题写法不一致也会妨碍全仓库搜索。 ## 决策 -`packages/<group>/<pkg>/package.json` 下的每个 package manifest(元数据清单)都有一个同级 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。该章节的条目记录该 package 拥有的持久性消费方缺口与非显而易见的维护约束;普通的清理工作留在源码 TODO 或所属 RFC 中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从 manifest 推导 package 集合,拒绝缺少 README 的情况,并要求恰好有一个规范的 h2 标题且至少包含一个顶级条目。近似标题(如 "Limitations"、"Deferred"、"What is NOT here" 或 "Non-goals")会导致失败。 +`packages/<group>/<pkg>/package.json` 下的每个包(package)manifest(元数据清单)都有一个同目录的 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。该章节的条目记录该包拥有的持久性消费方缺口与非显而易见的维护者约束;常规清理工作仍留在源码 TODO 或所属 RFC 中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从 manifest 推导包集合,拒绝缺少 README 的情况,并要求恰好有一个规范的 h2 标题且至少包含一个顶级条目。近似标题(如 "Limitations"、"Deferred"、"What is NOT here" 或 "Non-goals")会导致失败。 -如果一个 package 确实没有需要声明的限制,则将其列入 `NO_LIMITATIONS` 并省略该章节。新增限制时必须移除该条目;重命名或删除条目会失败,因为每个条目必须对应一个被扫描的 package。 +如果一个包确实没有需要声明的限制事项,则将其列入 `NO_LIMITATIONS` 并省略该章节。新增限制事项时须移除该条目;重命名或移除条目会失败,因为每个条目都必须对应一个被扫描的包。 -门禁检查存在性、格式和白名单。覆盖率与准确性由文档标准和[行文标准](../../../../.agents/skills/dsh-prose-standard/SKILL.md)下的评审负责。常设规则见 [packages/AGENTS.md](../../../../packages/AGENTS.md)。 +门禁检查的是存在性、格式与白名单。覆盖面和准确性由文档标准与 [prose 标准](../../../../.agents/skills/dsh-prose-standard/SKILL.md)下的评审负责。常设规则见 [packages/AGENTS.md](../../../../packages/AGENTS.md)。 ## 曾考虑的替代方案 -- **自由格式标题**:无法统一搜索,仍然需要近似标题检测。 -- **要求空章节或写 "None."**:样板文字可能在 package 新增限制后仍然残留;白名单使「确认无限制」显式且可评审。 -- **施加字数上限**:合理的限制条目数量因 package 而异,因此由评审管控这一不设预算的 README 层级。 +- **自由格式标题**:无法统一搜索,仍需近似标题检测。 +- **要求空章节或写 "None."**:样板文字可能在包新增限制事项后仍然残留;白名单使「确无限制」这一状态显式且可评审。 +- **设置字数上限**:合理的限制事项数量因包而异,因此由评审管控这一不设预算的 README 层级。 ## 后果 -- 新 package 要么声明符合条件的限制事项,要么显式加入白名单;缺失、漂移或空白的章节会在本地和 CI 的 `doc-sync` 中失败。 -- 门禁向 `doc-sync` 新增一个无外部依赖的 TypeScript 脚本。 -- 重命名被强制的标题需要同时修改脚本和所有 package README。 +- 新建的包须声明符合条件的限制事项,或显式加入白名单;缺失、漂移或空的章节会在本地和 CI 的 `doc-sync` 中失败。 +- 门禁为 `doc-sync` 新增一个无外部依赖的 TypeScript 脚本。 +- 重命名受强制的标题需要同时修改脚本和所有 package README。 diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml index 7796a46dfc..9e9b379eb9 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-package-model-experience-contract.md: 036efbc9510d6d0ae9e3c52a5ba8f39647adc4c9 -2026-07-12-package-model-experience-contract.zh.md: 6ee96f3befbb2ead7196f412dccf915f475cfc6d +2026-07-12-package-model-experience-contract.zh.md: b1efa712bc4f6fa7b23c0afe965e56eabf068d97 diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md index 6ee96f3bef..b1efa712bc 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md @@ -1,33 +1,33 @@ -# RFC:包(package)模型体验契约 - -Status: implemented +# RFC:Package Model Experience 契约 [English](2026-07-12-package-model-experience-contract.md) | 中文 +Status: implemented + ## 问题 -一个包的 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的核心问题:这个包中有什么内容会进入模型请求、在什么条件下进入、以及这些 token 会保留多久。在插件架构中,这一缺失尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能把成功替换为错误,压缩(compaction)可能移除旧历史,agent 作用域的注册可能改变某个 agent 的提示词或 schema 而对其他 agent 毫无影响。因此只阅读名义上面向模型的包会遗漏真实的上下文影响,而逐依赖阅读源码对于日常评审又过于昂贵。 +一个 package(包)的 README 可以解释 API 和运行时机制,却不回答那个主导 agent harness(智能体框架)行为与成本的问题:本 package 中有什么内容会进入模型请求、在什么条件下进入、以及这些 token 会保留多久。在插件架构中,这一缺失尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能把成功替换为错误,上下文压缩(context compaction)可能移除旧历史,agent 作用域的注册可能改变某个 agent 的提示词或 schema 而其他 agent 不受影响。因此,只阅读名义上面向模型的 package 会遗漏真实的上下文影响,而跨所有依赖阅读源码对日常评审来说又太昂贵。 ## 决策 -每个具有面向模型或模型相邻契约的 workspace 包 README,都以规范的 [Model Experience 章节](../../../cookbook/adding-a-package.md#4-write-the-package-readme)结尾,紧接在 `## Known Limitations and Deferred Work` 之前;如果包在 no-limitations 允许列表上,则以 Model Experience 本身结尾。经审计确认为模型无关的通用包通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 +每个具有面向模型或模型相邻契约的 workspace package README,在末尾、`## Known Limitations and Deferred Work` 之前放置规范的 [Model Experience 章节](../../../cookbook/adding-a-package.md#4-write-the-package-readme);位于 no-limitations 允许列表上的 package 以 Model Experience 本身作为末尾章节。经审计确认为模型无关的通用 package 通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 -具有直接、条件性、有上限、生命周期性、多表面或辅助模型效应的包,每个上下文表面使用一个 H3。每个 H3 说明相关模型接收到什么内容、何时接收,并对 token 效应进行分类。包所拥有的稳定文本逐字引用:系统提示词及其他长文本使用嵌套 H4 加 `markdown` 围栏,短文本则以行内形式保留,带命名的插值占位符。工具 schema 表面链接到生成的[工具目录](../../../tool-catalog.md)中对应的锚点章节,只陈述组合或配置差异;仅在运行时定义的则说明目录为何未收录。数据依赖和提供方拥有的文本以摘要形式呈现。agent 作用域的可见性须显式标注;当作用域可以隐藏提示词而不隐藏 schema(或反之)时,提示词和 schema 表面保持分开记录。 +具有直接、条件性、有上限、生命周期性、多表面或辅助模型效应的 package,每个上下文表面使用一个 H3。每个 H3 说明相关模型接收到什么内容以及何时接收,然后对 token 效应进行分类。由 package 拥有的稳定文本逐字引用:系统提示词行文和其他长字面量使用嵌套 H4 加 `markdown` 围栏,短字面量则以行内形式呈现并使用命名插值占位符。工具 schema 表面链接到生成的[工具目录](../../../tool-catalog.md)中对应的锚定章节,仅说明组合或配置差异;仅在运行时定义的工具则解释为何目录中未收录。数据依赖和提供方拥有的文本以摘要形式描述。agent 作用域的可见性须显式说明;当作用域可以隐藏其中一个而不影响另一个时,提示词表面与 schema 表面保持分开。 -没有模型上下文效应的包,或其路径完全由另一个包渲染的包,使用验证器审计过的单句形式:`None, as ` 或 `Indirectly, through `。纯传输和无 ctx key 的测试支持包在不产生模型绑定内容时使用 none 形式。提供方后端即使会截断或过滤数据,也使用 indirect 形式;组装 bundle 在所有效应由具名子包拥有时同样使用 indirect 形式。这些句子定位贡献所在,而不重述消费方的内容。结构化章节同样只记录包自身拥有的输入、转换和差异。 +没有模型上下文效应的 package,或其路径完全由另一个 package 渲染的 package,使用验证器审计过的单句形式:`None, as ` 或 `Indirectly, through `。纯传输和无密钥的测试支持 package 在不创建模型绑定内容时使用 none 形式。提供方后端即使对数据进行上限或过滤,也使用 indirect 形式;组装 bundle 在命名子 package 拥有全部效应时同样使用 indirect 形式。这些句子定位贡献所在,而不重述消费方的内容。结构化章节同样只记录 package 自身拥有的输入、变换和差异。 -`verify-package-readme-model-experience` 发现包的 manifest 并验证三种分类、规范的末尾章节顺序、必填字段、具体的文本证据、嵌套的逐字块以及锚定的工具目录链接。它在 `doc-sync` 和并行门禁运行器中运行。覆盖面、链接相关性和事实准确性仍由评审把关。 +`verify-package-readme-model-experience` 发现 package manifest(元数据清单)并验证三种分类、规范的末尾章节顺序、必填字段、具体字面量证据、嵌套逐字块和锚定的工具目录链接。它在 doc-sync(文档同步门禁)和并行门禁运行器中执行。覆盖面、链接相关性和事实准确性仍由评审把关。 ## 曾考虑的替代方案 -- **只记录注册了提示词或工具的包**:否决。后端、策略插件、适配器、持久化、作用域和压缩都会改变 token 的内容或生命周期,却不拥有面向模型的 schema。 -- **从源码生成一份中央上下文成本目录**:否决。AST 能找到注册点,但无法推断语义条件,例如历史保留、输出截断、父子可见性或辅助模型边界。包 README 是实现本地的契约;中央副本会增加又一个漂移面。 -- **要求给出数值 token 计数**:否决。精确计数取决于所选模型的 tokenizer、适配器序列化方式、配置和运行时数据。稳定的契约是增长形态:每请求固定、每调用条件性、保留、替换、有上限或零直接。 -- **使用三列表格**:否决。精确的源文本和条件性结果形态使单元格过于密集、难以扫读。重复的子章节为每个上下文表面提供可读的纵向空间,同时保留相同的字段。 -- **允许所有零影响包省略该章节**:否决。无约束的缺失在「经审计的零影响」和「忘了写文档」之间是歧义的。省略仅限于在验证器中以理由具名的模型无关通用包;模型相邻的零影响包保留一句显式说明。 -- **要求经审计的零影响或简单间接包也使用完整结构化形式**:否决。围绕一个事实重复标签没有意义。一句受门禁约束的句子在保持显式覆盖的同时免去了仪式感。 -- **只有约定、没有门禁**:否决。仓库级契约必须覆盖未来的每个包;评审者的记忆无法可靠地检测到遗漏的 README 章节。 +- **只记录注册提示词或工具的 package**:否决。后端、策略插件、适配器、持久化、作用域和压缩都会改变 token 的内容或生命周期,却不拥有面向模型的 schema。 +- **从源码生成一份集中式上下文成本目录**:否决。AST 能找到注册点,但无法推断语义条件,如历史保留、输出截断、父子可见性或辅助模型边界。package README 是实现本地的契约;集中副本会增加又一个漂移面。 +- **要求给出精确 token 数**:否决。精确数量取决于所选模型的 tokenizer、适配器序列化方式、配置和运行时数据。稳定的契约是增长形状:每请求固定、每调用条件性、保留、替换、有上限或零直接影响。 +- **使用三列表格**:否决。精确的源文本和条件性结果形状使单元格密集且难以扫读。重复的子章节为每个上下文表面提供可读的纵向空间,同时保留相同的字段。 +- **允许所有零影响 package 省略该章节**:否决。无约束的缺失在「经审计的零影响」和「忘记写文档」之间有歧义。省略仅限于在验证器中以理由命名的模型无关通用 package;模型相邻的零影响 package 保留一句显式说明。 +- **对审计过的零影响或简单间接 package 也要求完整结构化形式**:否决。围绕一个事实重复标签没有意义。受门禁约束的单句保留了显式覆盖而无需繁文缛节。 +- **只有约定而无门禁**:否决。仓库级契约必须覆盖未来的每个 package;评审者的记忆无法可靠地检测到遗漏的 README 章节。 ## 后果 -评审者可以从任何面向模型或模型相邻的包出发,看到它对会话模型、子模型和辅助调用的贡献,而无需重建完整的插件图。token 预算工作可以区分每次请求的重复开销与数据依赖的历史,agent 作用域的变更有了显式的文档检查点。包作者在模型可见行为变化时维护一个或多个紧凑的上下文表面块,或一句经分类的句子;经审计的通用包不带无关的模型样板文字。结构化字段不承诺提供方精确的 token 计数;测量仍然是模型和负载特定的,而文档化的增长形态与可见性契约保持稳定。 +评审者可以从任何面向模型或模型相邻的 package 出发,直接看到它对会话模型、子模型和辅助调用的贡献,无需重建完整的插件图。token 预算工作可以区分每次请求的重复开销与数据依赖的历史,agent 作用域的变更有了显式的文档检查点。package 作者在模型可见行为变更时维护一个或多个紧凑的上下文表面块或一句分类说明;经审计的通用 package 不承载无关的模型样板文字。结构化字段不承诺提供方精确的 token 数;测量仍然是模型和负载特定的,而文档化的增长与可见性契约保持稳定。 diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index f88f300193..7d48003ed6 100644 --- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-drop-mutable-session-summary.md: 0d790191906a9128ad12d40526fde3b9f8fa939f -2026-06-19-drop-mutable-session-summary.zh.md: 97293c296cd74b5a33ce7e1ebe473c970ee95d57 +2026-06-19-drop-mutable-session-summary.zh.md: 6b234eb0dbdf764223e078519c810216c28603be diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index 97293c296c..6b234eb0db 100644 --- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -6,30 +6,30 @@ Status: implemented ## 问题 -[会话持久化 seam](../architecture/2026-06-14-session-persistence.md) 将会话的日志外元数据拆分为 `dsh-session` 拥有的两种类型:一个不可变的 `SessionHeader`(`version`、`id`、`createdAt`、`cwd?`、`parentSession?`),在创建时一次性写入;一个可变的 `SessionSummary`(`updatedAt`、`title?`、`firstPrompt?`),「无需触碰仅追加日志即可更新」。二者的联合类型为 `SessionMeta = SessionHeader & SessionSummary`,抽象的 `SessionPersistence` 服务为此多出第七个方法 `update(id, summary)`,用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁写一个独立的原子 `.summary.json` **伴随文件**(临时写入 + rename,尽力而为);SQLite 在追加事务内更新 `updated_at`/`title`/`first_prompt` **列**。 +[session-persistence seam](../architecture/2026-06-14-session-persistence.md) 将会话的日志外元数据拆分为 `dsh-session` 拥有的两种类型:一个不可变的 `SessionHeader`(`version`、`id`、`createdAt`、`cwd?`、`parentSession?`),在创建时一次性写入;一个可变的 `SessionSummary`(`updatedAt`、`title?`、`firstPrompt?`),「可在不触碰仅追加日志的情况下更新」。二者的联合类型为 `SessionMeta = SessionHeader & SessionSummary`,抽象的 `SessionPersistence` 服务为此多出第七个方法 `update(id, summary)`,用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁写一个独立的原子 `.summary.json` **伴随文件**(临时写入 + rename,尽力保证);SQLite 在追加事务内更新 `updated_at`/`title`/`first_prompt` **列**。 -摘要的设计初衷是服务于未来的会话选择器(通过 `updatedAt` 排序、用 `title`/`firstPrompt` 预览)。该选择器从未实现。对整个仓库的审计表明,`SessionSummary` 的全部表面积都是**死状态**: +摘要是为未来的会话选择器设计的(通过 `updatedAt` 排序近期会话,用 `title`/`firstPrompt` 做预览)。该选择器从未实现。对整个仓库的审计表明,`SessionSummary` 的全部表面积都是**死状态**: -- `SessionPersistence.update()` 的**生产调用方为零**(所有 `.update(` 命中都是 `createHash().update()` 或测试代码)。 +- `SessionPersistence.update()` **零个生产调用方**(所有 `.update(` 匹配都是 `createHash().update()` 或测试代码)。 - `firstPrompt` 在生产代码中**从未被读取**。 -- `title` 确实在 ACP bridge 中被读取,但来源是工具调用的 **presenter**(`present.title`),而非存储的会话元数据。 -- `updatedAt` **没有消费方**:`list()` 唯一的生产调用方读取的是 `meta.cwd`(`SessionHeader` 字段),用于在 `session/load` 时校验工作区;resume 读取的是 `createdAt`/`cwd`/`parentSession`,全部是 header 字段。 -- 决定性的事实:活跃的 `Session.header` 早已被类型化为 `SessionHeader` 而非 `SessionMeta`——摘要从未存在于活跃会话对象上;它只存在于持久化层,除了自身的契约测试之外无人写入、无人读取。 +- `title` 确实在 ACP 桥接层被读取过,但读的是工具调用的 **presenter**(`present.title`),从未读取存储的会话元数据。 +- `updatedAt` **没有消费方**:`list()` 唯一的生产调用方读取的是 `meta.cwd`(`SessionHeader` 字段),用于在 `session/load` 时校验工作区;恢复会话读取的是 `createdAt`/`cwd`/`parentSession`——全是 header 字段。 +- 决定性的一点:活跃的 `Session.header` 类型本来就是 `SessionHeader` 而非 `SessionMeta`——摘要从未存在于活跃会话对象上;它只存在于持久化层,除了自身的契约测试外无人写入、无人读取。 ## 决策 -彻底删除可变的会话摘要。`SessionSummary` 与 `SessionMeta` 这个名称一并移除;后端存储和返回的元数据仅为 `SessionHeader`。`SessionPersistence.update()` 从抽象服务和所有后端中移除。JSONL 去掉整套伴随文件机制(`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` 以及 load/list 的覆盖逻辑);SQLite 删除 `updated_at`/`title`/`first_prompt` 列及每次追加时的 `updated_at` 更新,其 `SCHEMA_VERSION` 从 `1 → 2`。 +彻底删除可变的会话摘要。`SessionSummary` 与 `SessionMeta` 这个名称一并移除;后端存储和返回的元数据仅为 `SessionHeader`。`SessionPersistence.update()` 从抽象服务和所有后端中移除。JSONL 去掉整套伴随文件机制(`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` 以及 load/list 的覆盖逻辑);SQLite 去掉 `updated_at`/`title`/`first_prompt` 列以及每次追加时的 `updated_at` 更新,其 `SCHEMA_VERSION` 从 `1 → 2`。 -摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;最近活跃时间 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变的 header 中(`createdAt`、`cwd`)。唯一*不可*派生的——用户*手动编辑*的标题——没有任何实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 +摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一不可派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 -将此记录为决策,是因为它**持久**(收窄了一个公开服务契约和两个后端的磁盘格式)、**有争议**(摘要是有意的前瞻性设计,不是意外产物)、**出人意料**(未来读者看到 `SessionHeader` 而原始 RFC 描述的是 `SessionMeta`,否则会疑惑摘要为何消失)。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md)扫清了障碍:没有可变摘要,协调器的钩子接口就不需要 `updateSummary` 钩子,JSONL 伴随文件与 SQLite 列之间的持久性差异也随之消失,两个后端的写入路径得以收敛。 +将此记录为决策,原因有三:**持久性**(它收窄了一个公开服务契约和跨两个后端的磁盘格式)、**争议性**(摘要是有意的前瞻性设计,而非意外产物)、**意外性**(未来读者看到 `SessionHeader` 而原始 RFC 描述的是 `SessionMeta`,否则会疑惑摘要为何消失)。它还为 [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md) 扫清了障碍:没有可变摘要后,协调器的钩子接口无需 `updateSummary` 钩子,JSONL 伴随文件与 SQLite 列之间的持久性分歧也随之消失,两个后端的写入路径得以统一。 ## 无需迁移 -这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「预发布立场:地基优先于爆炸半径」一节),因此不存在需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论更旧还是更新,因此陈旧的 v1 数据库会被干净地拒绝,而非在新列集上半读半错。新建数据库写入当前版本号;这是唯一需要工作的路径。 +这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「Pre-release stance: foundation over blast radius」一节),因此没有需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论更旧还是更新,因此陈旧的 v1 数据库会被干净地拒绝,而非在新列集下被半读取。新建数据库写入当前版本号;这是唯一需要正常工作的路径。 ## 后果 -未来的会话选择器现在必须从日志派生预览和排序信息(或重新引入一个类型化字段),而不能直接读取现成的摘要行。这是正确的代价:为一个不存在的功能维护缓存,是每个后端都要承担的死重,也是每个契约测试都要断言的负担。这一原则——**通过的测试固定的是当前行为,不一定是正确行为;行为可能是过去妥协的产物**——现已作为独立约定记录在[根 AGENTS.md](../../../../AGENTS.md) 中,本次变更即为其实例。 +未来的会话选择器现在必须从日志派生预览/排序信息(或重新引入一个类型化字段),而不能直接读取现成的摘要行。这是正确的代价:为一个尚不存在的功能维护缓存,是每个后端都要付出维护成本、每个契约测试都要付出断言成本的死重。这一原则——**通过的测试固定的是当前行为,不一定是正确行为;行为可能是过去妥协的产物**——现已作为独立约定记录在[根 AGENTS.md](../../../../AGENTS.md) 中,本次变更即为其实例。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml index 0e73dbfe7e..807d67d2fc 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-collapse-trace-only-session-events.md: 9156c2ab356b1c46758d9d2047d491cd1952cbc4 -2026-06-20-collapse-trace-only-session-events.zh.md: f2ecfbf46d8d70cf478d57eae2ed3a18ea8746fa +2026-06-20-collapse-trace-only-session-events.zh.md: c4555f3a772096fc36de968d5d3085f5a2e879f3 diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md index f2ecfbf46d..c4555f3a77 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -6,39 +6,39 @@ Status: implemented ## 问题 -会话事件词汇中包含一些一等事件,它们既不属于可回放的对话历史,在生产环境中也几乎没有消费方。`usage` 在模型流式分片中已经存在,但循环又额外追加了一个独立的 `usage` 事件。`error` 与 `turn/end { kind: 'error', message, code }` 中的循环失败原因重复;ACP(Agent Client Protocol)结算读取的是 turn-end 原因,ACP 渲染忽略 `error` 事件,`deriveMessages()` 也跳过它。 +会话事件词汇中包含一些一等事件,它们不属于可回放的对话历史,在生产环境中几乎没有消费方。`usage` 已经作为模型流分片存在,之后循环又追加了一个独立的 `usage` 事件。`error` 与 `turn/end { kind: 'error', message, code }` 中的循环失败原因重复;ACP(Agent Client Protocol)结算读取 turn-end 原因,ACP 渲染忽略 `error` 事件,`deriveMessages()` 也跳过它。 -这些事件让规范的 transcript(文本记录)看起来比实际更像遥测数据。它们增加了事件变体、不变式、测试、快照和持久化用例,但作为独立记录并不承载实际负荷。它们携带的事实仍然有用:token 用量应当保留以供核算,错误的步骤编号也不应悄然消失。简化的方式是将这些事实折叠进消费方本就必须理解的邻近事件,而非减少记录的信息量。 +这些事件让规范的 transcript(文本记录)看起来比实际更像遥测数据。它们增加了事件变体、不变式、测试、快照和持久化用例,但作为独立记录并不承载实际功能。它们携带的事实仍然有用:token 用量应当保留以供计费,错误的步骤编号也不应悄然消失。简化的方式是将这些事实折叠进消费方本已必须理解的邻近事件,而非减少记录的信息量。 ## 决策 -仅在信息已被保留、无需并行记录的位置移除独立的追踪事件: +仅在信息已被保留、无需并行记录的情况下,移除独立的追踪事件: -- 成功步骤的 usage 折叠进对应的 `assistant/message`(`assistant/message { turn, step, content, usage? }`),使组装好的模型输出与其核算信息一同传递。 -- 失败或中止的步骤如果有 usage 但没有 assistant 内容,则将 usage 挂在一个空内容的 `assistant/message` 上(下方实现说明给出了无信息丢失的证明)——不会有任何已持久化的 usage 分片失去表示。 -- 独立 `error` 事件中的步骤编号折叠进 `turn/end.reason`(当 `kind: 'error'` 时:`{ kind: 'error', step, message, code? }`)——`turn/end` 是 ACP 和恢复机制已在消费的持久化轮次结果。 -- `agent/error` 和日志保留用于实时诊断;`turn/end` 之后不再有第二条会话日志错误记录。 +- 成功步骤的 usage 折叠进匹配的 `assistant/message`(`assistant/message { turn, step, content, usage? }`),使组装好的模型输出与其计费信息一同传递。 +- 失败或中止的步骤如果有 usage 但没有 assistant 内容,则将 usage 放在一个空内容的 `assistant/message` 上(下方实现说明给出了无信息丢失的证明)——不会有已持久化的 usage 分片无处安放。 +- 独立 `error` 事件中的步骤编号折叠进 `turn/end.reason`(当 `kind: 'error'` 时:`{ kind: 'error', step, message, code? }`)——`turn/end` 是 ACP 和恢复机制已经消费的持久轮次结果。 +- `agent/error` 与日志保留用于实时诊断;`turn/end` 之后不再有第二条会话日志错误记录。 -用户对话日志包含渲染、恢复、审计和核算交互所需的全部信息,消费方无需对账重复的追踪行。 +用户对话日志包含渲染、恢复、审计和计费所需的全部信息,消费方无需协调重复的追踪行。 ## 曾考虑的替代方案 -**保留独立行作为遥测**:这些事件让规范的 transcript 看起来比实际更像遥测数据,代价是增加了事件变体、不变式、测试、快照和持久化用例,却没有消费方使用。如果分析需求真正出现,正确的形态是投影辅助工具或带有独立保留策略的专用遥测存储,而非在对话日志中放置重复的追踪行。 +**保留独立行作为遥测**——这些事件让规范 transcript 看起来比实际更像遥测数据,代价是增加了事件变体、不变式、测试、快照和持久化用例,却没有任何消费方使用。如果分析需求真正出现,正确的形态是投影辅助工具或带有独立保留策略的专用遥测存储,而非对话日志中的重复追踪行。 ## 验证 -`SessionEventMap` 不再包含独立的 `usage` 或 `error`;循环不再追加独立的 usage 事件,持久化的失败通过 `turn/end { kind: 'error', step, message, code? }` 记录;ACP 快照和持久化测试断言不存在仅追踪行;录制的 fixture(测试前置数据)已采用新事件形状,会话格式版本固定为 `0`(按预发布格式策略,后端拒绝任何非 `0` 的存储日志);文档说明了 token 用量和操作错误的观测位置。 +`SessionEventMap` 不再包含独立的 `usage` 或 `error`;agent loop(智能体循环)不再追加独立的 usage 事件,持久性失败通过 `turn/end { kind: 'error', step, message, code? }` 记录;ACP 快照和持久化测试断言不存在仅追踪行;已录制的 fixture(测试前置数据)使用新事件形状,会话格式版本固定为 `0`(后端按预发布格式策略拒绝任何非 `0` 的存储日志);文档说明了 token 用量和操作错误的观测位置。 ## 后果 -消费方不能再从规范日志中筛选独立的 `usage` 或步骤级 `error` 行,必须从承载它们的 assistant/failure 事件中读取这些事实。只有当实现 PR 证明相同的事实仍然存在时,这才是合理的简化;否则独立事件应当保留。 +消费方不能再从规范日志中筛选独立的 `usage` 或步骤级 `error` 行,必须从承载它们的 assistant/failure 事件中读取这些事实。只有在实现 PR(Pull Request)证明相同事实仍然存在的前提下,这才是合理的简化;否则独立事件应予保留。 ## 实现说明 按提案交付,有一处范围细化(遵循 AGENTS.md「RFC 是提案,不是金科玉律」): -- **空内容的 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片失去表示)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),此前会发出独立的 `usage`。现在它记录一条空内容的 `assistant/message { content: [], usage }`。为避免这向提供方 transcript 注入一个无内容的虚假 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。一个回归测试断言 usage 仍有表示,且派生历史未被破坏。 +- **空内容 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片无处安放)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),以前会发出独立的 `usage`。现在它记录一个空内容的 `assistant/message { content: [], usage }`。为防止这向 provider transcript 注入一个无内容的虚假 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。回归测试断言 usage 仍被表示,且派生历史未被破坏。 -**格式版本。** 此变更改动了持久化事件,但预发布会话格式仍固定为 `0`,拒绝任何其他版本且不做迁移。`dsh-session` 拥有写入方和加载校验使用的常量。单调递增的格式版本从首次正式发布开始。 +**格式版本。** 此变更影响已持久化的事件,但预发布会话格式仍固定为 `0`,拒绝任何其他版本且不做迁移。`dsh-session` 拥有写入方和加载校验使用的常量。单调递增的格式版本从首次正式发布开始。 -Usage 现在通过 `assistant/message.usage` 观测;操作错误的步骤编号通过 `turn/end.reason`(当 `kind: 'error'` 时)观测。`agent/error` 加日志用于实时诊断,保持不变。 +Usage 现在通过 `assistant/message.usage` 观测;操作错误的步骤编号通过 `turn/end.reason`(当 `kind: 'error'` 时)观测。`agent/error` 与日志用于实时诊断,保持不变。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml index 8586b4051e..dbb62e578d 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unconsumed-llm-adapter-change-event.md: efe90c0197671ef4385ce517540b4b238962c3b5 -2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: 13f01566472a030cc9d4f97f6e4438fe4c3ecbf8 +2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: b26610cfe273820112113c73b9313557cd78262c diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md index 13f0156647..b26610cfe2 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md @@ -1,4 +1,4 @@ -# RFC:移除无消费方的 `llm/adapter-change` 事件 +# RFC:移除未被消费的 `llm/adapter-change` 事件 Status: implemented @@ -6,31 +6,31 @@ Status: implemented ## 问题 -`LlmService.registerAdapter()` 在注册和 dispose(资源释放)时发射 `llm/adapter-change`([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))。在 `packages/*/src` 和 `examples/*/src` 中 grep `llm/adapter-change`,只能找到声明、发射点、文档和测试;没有任何生产代码监听它。 +`LlmService.registerAdapter()` 在注册和 dispose(资源释放)时发出 `llm/adapter-change` 事件([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))。在 `packages/*/src` 和 `examples/*/src` 中搜索 `llm/adapter-change`,只能找到声明、emit 站点、文档和测试;没有任何生产环境的监听器订阅它。 -这与 `tools/change` 和 `system-prompt/change` 不同。后两个事件目前同样无消费方,但它们是合理的注册表变更信号,未来的实时工具/提示词 UI 可能用到。LLM 适配器注册更像是启动时的实现细节:适配器不是用户可见的面板,真正的模型调用拦截 seam 是 `llm/stream`。保留一个没有监听者的 adapter-change 事件,是 [drop-the-dead-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 模式在更小尺度上的重复。 +这与 `tools/change` 和 `system-prompt/change` 不同。后两个事件目前同样未被消费,但它们是合理的注册表变更信号,未来可能服务于实时工具/提示词 UI。LLM(大语言模型)适配器注册更接近启动时的实现细节:适配器不是用户可见的面板,真正的模型调用拦截 seam 是 `llm/stream`。保留一个没有监听器的 adapter-change 事件,是在更小规模上重复 [drop-the-dead-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 的模式。 -这个事件并非零成本。`registerAdapter()` 在发射 `llm/adapter-change` 之前先 yield 回滚 disposer,这样抛异常的监听者会回退变更而不是泄漏一条适配器条目;包里还有测试覆盖这条监听者抛异常的路径。这种防御性排序所保护的失败模式,只有测试才能触发。 +这个事件并非零成本。`registerAdapter()` 在发出 `llm/adapter-change` 之前先 yield 回滚 disposer,这样抛出异常的监听器会回退变更而非泄漏适配器条目;包内还有针对该监听器抛出路径的测试。这种防御性排序保护的是一个只有测试才能触发的失败模式。 ## 决策 -只移除 `llm/adapter-change`:`dsh-llm` 的 `interface Events` 中的声明、`ctx.emit('llm/adapter-change')` 调用,以及 `LlmService.registerAdapter` JSDoc 中「在注册和 dispose 时发射 `llm/adapter-change`」的描述。`registerAdapter()` 的 effect generator 保留变更与回滚 disposer(用于 HMR(热模块替换)/dispose),但去掉仅为已移除事件而存在的监听者抛异常回滚排序。适配器 disposer 测试断言返回的 disposer 能移除适配器,不再订阅该事件;监听者抛异常的回滚测试随其主题一同移除。[docs/architecture.md](../../../architecture.md) 和 [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) 中的事件分类体系在同一个变更中更新。 +仅移除 `llm/adapter-change`:`dsh-llm` 的 `interface Events` 中的声明、`ctx.emit('llm/adapter-change')` 调用,以及 `LlmService.registerAdapter` JSDoc 中的 "Emits `llm/adapter-change` on registration and disposal" 语句。`registerAdapter()` 的 effect generator 保留变更与回滚 disposer 以支持 HMR(热模块替换)/dispose,但去掉了仅为已移除事件而存在的监听器抛出回滚排序。适配器 disposer 测试断言返回的 disposer 能移除适配器,而不再订阅该事件;监听器抛出回滚测试随其主题一同移除。[docs/architecture.md](../../../architecture.md) 和 [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) 中的事件分类体系在同一个变更中更新。 ## 曾考虑的替代方案 ### 为什么不移除所有注册表变更事件? -一个注册表主动广播变更的微内核是一种自洽的约定。`tools/change` 和 `system-prompt/change` 在 UI 能实时刷新可用工具或提示词段落时可能变得有用。本 RFC 在有合理的面向用户消费方的地方保留该约定,仅裁掉当前和可预见未来都没有明确消费方的 adapter-change 事件。 +一个注册表主动广播变更的微内核是一种自洽的约定。`tools/change` 和 `system-prompt/change` 在 UI 能实时刷新可用工具或提示词段落时可能变得有用。本 RFC 保留该约定中有合理的面向用户消费方的部分,仅裁掉当前和可预见未来消费方都不明确的 adapter-change 事件。 -如果将来需要 LLM 适配器浏览器或动态模型选择器,届时再连同消费方一起重新引入该事件,并给出比「something changed」更清晰的 payload。 +如果将来需要 LLM 适配器浏览器或动态模型选择器用到此信号,届时再连同消费方一起重新引入,并提供比「something changed」更清晰的 payload。 ## 验证 -`llm/adapter-change` 及其发射点已移除,重新生成的 cordis catalog 是最新的;HMR 安全性保持(dispose 一个贡献 fiber 会移除对应适配器);`tools/change` 和 `system-prompt/change` 仍有文档和测试;没有任何生产路径的可观测行为发生变化——ACP 快照 golden 和 echo-agent 冒烟测试逐字节不变。 +`llm/adapter-change` 及其 emit 已移除,重新生成的 cordis catalog 是最新的;HMR 安全性保持(dispose 一个贡献 fiber 会移除对应适配器);`tools/change` 和 `system-prompt/change` 仍有文档和测试;没有任何生产路径的可观察行为发生变化——ACP(Agent Client Protocol)快照 golden 和 echo-agent 冒烟测试逐字节未变。 ## 后果 -- **移除一个已文档化的发射事件属于公开接口变更。** 它出现在分类体系表中,读起来像是有意为之的 API。但「已声明并发射」不等于「有消费方」——这正是当初移除可变 summary 时所依据的同一区分。分类体系表在同一个变更中更新,因此文档不会漂移。 -- **注册表变更约定变得不均匀。** 这是可以接受的,因为 LLM 适配器注册与工具或提示词段落不是同一层面的用户可见概念。不均匀但诚实,胜过统一但空转。 +- **移除一个已文档化的 emit 事件属于公开接口变更。** 它出现在分类体系表中,读起来像有意设计的 API。但「已声明且已发出」不等于「已被消费」——这与移除可变 summary 时的判断依据相同。分类体系表在同一个变更中更新,因此文档不会漂移。 +- **注册表变更约定变得不均匀。** 这是可接受的,因为 LLM 适配器注册与工具或提示词段落不是同一层面的面向用户概念。不均匀但诚实,胜过统一但无用。 -这是一个小裁剪,但它退役了一条守护着不存在的消费方的常设正确性不变式。 +这是一个小裁剪,但它退役了一条守护着并不存在的消费方的正确性不变式。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml index 46b752f26a..0d895a2b35 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: c8999dd0e19b2c8eaff854c8ff544bae2fc068b6 -2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 090d3e779e3e7a9f1f0a65af7740ad685f723cf6 +2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: de297a8cb64d9002fce2c857fd3caa5f2b25f43a diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md index 090d3e779e..de297a8cb6 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md @@ -1,4 +1,4 @@ -# RFC:移除未被消费的 LLM 组装便利接口 +# RFC:移除未被消费的 LLM 组装便捷接口 Status: implemented @@ -9,31 +9,31 @@ Status: implemented `LlmService`([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))在模型之上暴露了三个调用接口: - `stream()`:原始 `StreamChunk`,通过 `llm/stream` waterfall(瀑布式事件)分发。 -- `streamBlocks()`:一个"便利视图",将 chunk 送入 `BlockAssembler` 并按流顺序 yield 已组装完成的 `ContentBlock`([index.ts:137-144](../../../../packages/llm/llm/src/index.ts))。 -- `generate()`:一个完整组装的 `GenerateResult`,通过第二个 `llm/generate` waterfall 分发([index.ts:151-157](../../../../packages/llm/llm/src/index.ts))。 +- `streamBlocks()`:一个「便捷视图」,将分片送入 `BlockAssembler` 并按流顺序产出已组装的 `ContentBlock`([index.ts:137-144](../../../../packages/llm/llm/src/index.ts))。 +- `generate()`:一个完整组装的 `GenerateResult`,通过第二条 `llm/generate` waterfall 分发([index.ts:151-157](../../../../packages/llm/llm/src/index.ts))。 -LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体循环),它只使用 `stream()`:将原始 chunk 送入自己的 `BlockAssembler`,以便在并行组装的同时记录 chunk 用于回放保真([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts) 中的 `ctx.llm.stream(req)` 步骤)。在 `packages/*/src` 和 `examples/*/src` 中搜索 `streamBlocks` 与 `ctx.llm.generate`,找不到任何生产调用方。引用它们的只有服务方法定义、文档和测试;适配器测试用 `generate()` 作为便利驱动,但它们完全可以通过同一个 assembler 辅助函数手动消费 `stream()`,无需保留一个公开的生产 API。 +LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体循环),它只使用 `stream()`:将原始分片送入自己的 `BlockAssembler`,以便在并行组装的同时记录分片,保证回放保真度([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts),`ctx.llm.stream(req)` 步骤)。在 `packages/*/src` 和 `examples/*/src` 中 grep `streamBlocks` 与 `ctx.llm.generate`,找不到任何生产调用方。仅有的引用来自服务方法定义、文档和测试;适配器测试用 `generate()` 作为便捷驱动,但它们完全可以通过同一个 assembler 辅助函数手动消费 `stream()`,无需为此保留一个公开的生产 API。 -这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:拥有测试契约的组装视图 API,消费方只有测试而非生产代码。它们是为"不关心 token 级增量"的消费方预先构建的,但唯一的真实消费方恰恰需要增量,以便持久化高保真的回放数据。 +这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:拥有经过测试的契约的组装视图 API,消费方却只有测试而非生产代码。它们是为「不关心 token 级增量」的消费方预设的,但唯一的真实消费方恰恰需要增量,以便持久化高保真回放数据。 -`streamBlocks()` 拖带了 `BlockAssembler` 中一块专用逻辑:`flushReady()` 和 `flushRemaining()`([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts))以及 `flushed` 游标字段,仅为支持按序增量 yield 而存在。`generate()` 拖带了 `GenerateResult`、`BlockAssembler.result()` 以及 `llm/generate` waterfall——在同一底层流之上多出的第二个拦截面。agent loop 对 assembler 的使用仅限 `push()` / `message()` / `usage` / `finish`,不涉及流式 flush 或一次性服务组装。 +`streamBlocks()` 拖带了 `BlockAssembler` 的一块专用逻辑:`flushReady()` 与 `flushRemaining()`([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts))以及 `flushed` 游标字段,仅为支持按序增量产出而存在。`generate()` 拖带了 `GenerateResult`、`BlockAssembler.result()` 以及 `llm/generate` waterfall——在同一底层流之上的第二个拦截面。agent loop 对 assembler 的使用仅限于 `push()` / `message()` / `usage` / `finish`,不涉及流式 flush 或一次性服务组装。 ## 决策 -`stream()` 是唯一的公开 LLM 调用接口。移除 `streamBlocks`、`generate`、其事件/结果类型,以及仅被该路径使用的 assembler 辅助方法。适配器测试通过本地辅助函数对公开的 stream 进行组装,`BlockAssembler` 只保留有生产消费方的操作。 +`stream()` 是唯一的公开 LLM 调用接口。移除 `streamBlocks`、`generate`、其事件/结果类型,以及仅被该路径使用的 assembler 辅助方法。适配器测试通过本地辅助函数对公开的 stream 进行组装;`BlockAssembler` 仅保留有生产消费方的操作。 ## 曾考虑的替代方案 -**保留 `generate()` 作为仅供测试的便利方法**:否决。适配器测试通过共享 assembler 手动消费 `stream()`,走的是与生产相同的流式路径;一个唯一调用方是测试的公开方法,正是[移除可变摘要先例](2026-06-19-drop-mutable-session-summary.md)所清退的死接口形态。未来如果有消费方需要不带增量的组装块,届时再引入一个有真实消费方的专用辅助方法。 +**保留 `generate()` 作为仅供测试的便捷方法**:否决。适配器测试通过共享 assembler 手动消费 `stream()`,走的是与生产完全相同的流式路径;一个唯一调用方只有测试的公开方法,正是 [drop-mutable-summary 先例](2026-06-19-drop-mutable-session-summary.md)所淘汰的死接口形态。未来如果有消费方需要不带增量的组装块,届时再为该消费方引入一个聚焦的辅助方法。 ## 验证 -`streamBlocks`、`generate`、`llm/generate` 以及仅被它们使用的 assembler 辅助方法已全部移除,无新增死导出;两个真实适配器通过 `stream()` 加共享 assembler 得到充分测试;agent loop 行为不变(ACP 快照 golden 文件无变化);README、架构文档与模块文档中不再提及被移除的接口。 +`streamBlocks`、`generate`、`llm/generate` 及其独占的 assembler 辅助方法已移除,无新增死导出;两个真实适配器通过 `stream()` 和共享 assembler 得到验证;agent loop 行为不变(ACP 快照 golden 文件无变化);README、架构文档与模块文档中不再提及已移除的接口。 ## 后果 -- **从一个核心词汇包中移除了公开方法。** 未来如果有插件需要不带增量的组装块,它需要直接调用 `stream()` 并使用 `BlockAssembler`,或在有真实消费方时重新引入一个专用辅助方法。鉴于预发布阶段「基础优先于投机性未来」的立场([AGENTS.md](../../../../AGENTS.md)),现在正是清除仅供测试的公开形状的正确时机。 -- **适配器测试变得更显式。** 它们失去了便利的 `generate()` 包装层,但这是有益的压力:测试走的是与生产相同的流式路径。 -- **waterfall 使用方失去 `llm/generate`。** 不存在生产监听者。未来的缓存/重试/日志插件应包装 `llm/stream`,它仍是唯一的提供方调用路径。 +- **从一个核心词汇包中移除了公开方法。** 未来如果有插件需要不带增量的组装块,它需要直接调用 `stream()` 并使用 `BlockAssembler`,或在有真实消费方时重新引入一个聚焦的辅助方法。鉴于预发布阶段「基础优先于预设未来」的立场([AGENTS.md](../../../../AGENTS.md)),现在正是裁剪仅供测试的公开接口的合适时机。 +- **适配器测试变得更显式。** 它们失去了便捷的 `generate()` 包装层,但这是有益的压力:测试走的是与生产相同的流式路径。 +- **waterfall 使用者失去 `llm/generate`。** 不存在生产监听者。未来的缓存/重试/日志插件应包装 `llm/stream`,它仍然是唯一的提供方调用路径。 -变更规模不大,但它干净地从 LLM 包中移除了投机性的接口面积,为生产和测试留下唯一一份模型调用契约。 +改动规模不大,但它从 LLM 包中干净地移除了预设的接口面积,为生产和测试留下唯一一份模型调用契约。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml index 20c7e3718a..e995e57540 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-prune-dead-seam-methods.md: cb3eb576dbae209ddccbea7f42a80ef09f842887 -2026-06-20-prune-dead-seam-methods.zh.md: d3c658ea2ce994e640ec4fd2cf5f74d82f695e69 +2026-06-20-prune-dead-seam-methods.zh.md: 988da3c44d8860d89f090f0ea9e2af49ae9007dd diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md index d3c658ea2c..988da3c44d 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md @@ -1,43 +1,43 @@ -# RFC:清理持久化 seam 中的无用方法 +# RFC:从 persistence seam 中移除无用方法 [English](2026-06-20-prune-dead-seam-methods.md) | 中文 Status: implemented -> **实现说明:** 最终只移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 保留,因为移除它们的单行查找接口需要在消费方引入大量额外的完成状态跟踪机制。它们的 id 品牌化由 [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) 覆盖。 +> **实现说明:** 最终只移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 保留,因为移除它们的单行查询接口需要在消费方引入大量额外的完成状态追踪机制。它们的 id 品牌化由 [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) 覆盖。 ## 问题 -一个能力 seam([接口/实现/消费方](../../implemented/architecture/2026-06-13-capability-seams.md))携带了没有任何消费方调用的抽象方法。seam 存在的意义是让实现与消费方独立演进,但一个没有消费方编程依赖的方法不是 seam,而是投机性的接口面——每个实现仍然必须实现并测试它。 +一个能力 seam([接口/实现/消费方](../../implemented/architecture/2026-06-13-capability-seams.md))承载着没有任何消费方调用的抽象方法。seam 的存在是为了让实现与消费方独立演进,但一个没有消费方编程依赖的方法不是 seam,而是每个实现仍须实现和测试的投机性接口面。 ### `SessionPersistence.has()` 与 `.delete()` -抽象服务在 create/append 之外声明了更多操作:`load`、`list`、`has`、`delete`。`ctx.sessionPersistence` 的生产消费方只用到两个:agent loop(智能体循环)的恢复路径调用 `load()`([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)),ACP 桥接层为 `session/list` 调用 `list()`([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts))。在 `packages/*/src` 和 `examples/` 中 grep 所有 `sessionPersistence.*` / `persistence.*` 用法,找不到对该服务的 `has(` 或 `delete(` 调用。`packages/ui/acp/src/index.ts` 中的 `.has(`/`.delete(` 调用作用于内存中的 `SessionStore` 和一个本地的 loading id `Set`,而非持久化服务。`has`/`delete` 的唯一调用方是契约测试套件和各后端的 spec。 +该抽象服务在 create/append 之外声明了更多操作:`load`、`list`、`has`、`delete`。`ctx.sessionPersistence` 的生产消费方只用了两个:agent loop(智能体循环)的恢复路径调用 `load()`([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)),ACP(Agent Client Protocol)桥接层为 `session/list` 调用 `list()`([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts))。在 `packages/*/src` 和 `examples/` 中 grep 所有 `sessionPersistence.*` / `persistence.*` 的使用,找不到对该服务的 `has(` 或 `delete(` 调用。`packages/ui/acp/src/index.ts` 中的 `.has(`/`.delete(` 调用作用于内存中的 `SessionStore` 和一个本地的 loading id `Set`,而非 persistence。`has`/`delete` 的唯一调用者是契约测试套件和各后端的 spec。 -`has()` 不仅仅是未使用——它还是共享协调器中最复杂的分支:一个 tracked-vs-untracked 双探测(`loadLive(id, cwd)` 用于活跃跟踪的会话,`loadStored(id)` 用于未跟踪的会话),附带多行注释说明理由。`delete()` 则拖带了 `deleteStored` 后端钩子,每个后端都必须实现它。这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:契约测试覆盖了两者,但没有任何发布代码会问「这个会话是否已持久化?」或删除一个会话。 +`has()` 不仅是未使用——它还是共享协调器中最复杂的分支:一个 tracked-vs-untracked 双探测(`loadLive(id, cwd)` 用于活跃追踪的会话,`loadStored(id)` 用于未追踪的会话),附带多行注释说明理由。`delete()` 则拖带了 `deleteStored` 后端钩子,每个后端都必须实现它。这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:契约测试覆盖了两者,但没有任何发布代码会问「这个会话是否已持久化?」或删除一个会话。 ## 决策 没有消费方使用的方法被移除——从抽象 seam、实现,以及仅为覆盖它们而存在的契约/spec 测试套件中移除: -- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` 钩子(jsonl 和 sqlite 各自实现 `deleteStored` 仅仅是为了满足该钩子——那些实现也一并移除)。后端属于[双后端](../../implemented/architecture/2026-06-14-session-persistence.md)设计,本身不在本 RFC 范围内;移除它们为无消费方实现的钩子是移除钩子的一部分,而非后端重设计。 -- 所有文档和源码注释中的引用都已更新为存活的四方法、仅含 `list()` 的契约——不仅是字面的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和「六个公开方法」之类的计数——涉及 seam 和后端 README、[docs/architecture.md](../../../architecture.md)、[session-persistence](../../implemented/architecture/2026-06-14-session-persistence.md) 与 [write-coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) RFC,以及协调器/后端的 JSDoc。 +- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` 钩子(jsonl 和 sqlite 各自实现 `deleteStored` 仅为满足该钩子——这些实现也一并移除)。后端属于[双后端](../../implemented/architecture/2026-06-14-session-persistence.md)设计,本身不在本次范围内;移除它们为无消费方实现的钩子是移除钩子的一部分,而非后端重新设计。 +- 所有文档和源码注释中的引用都已更新为存留的四方法、仅含 `list()` 的契约——不仅是字面的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和「六个公开方法」之类的计数——涉及 seam 和后端 README、[docs/architecture.md](../../../architecture.md)、[session-persistence](../../implemented/architecture/2026-06-14-session-persistence.md) 和 [write-coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) RFC,以及协调器/后端的 JSDoc。 ## 曾考虑的替代方案 ### 为什么不以「seam 应当完整」为由保留? -「持久化 seam 理应提供 delete」这种直觉是真实的——而它恰恰是预发布阶段所警惕的投机完整性([AGENTS.md](../../../../AGENTS.md):为正确的基础优化,而非为你并不拥有的假想调用方优化)。`delete()` 只是一个方法,等到消费方真正需要时再加回来即可:一个删除旧会话的会话管理 UI 会需要它——到那时再加,针对该 UI 的真实需求设计(软删除?级联?确认?),而非现在猜测。 +「persistence seam 理应提供 delete」这种直觉是真实的——但它恰恰是预发布阶段所警惕的投机性完整([AGENTS.md](../../../../AGENTS.md):为正确的基础优化,而非为你并不拥有的假想调用者优化)。`delete()` 是一个方法,等消费方真正需要时再加回来即可:一个删除旧会话的会话管理 UI 会需要它——到那时再加,基于该 UI 的真实需求来设计(软删除?级联?确认?),而非现在猜测。 -在有活跃消费方时重新加入一个 seam 方法,成本低且设计更优,因为消费方锁定了契约。无人使用地携带它,意味着每个实现(以及未来的每个后端)都必须实现并测试一个什么也不做的方法。 +在有活跃消费方的情况下重新添加一个 seam 方法,成本低且设计更优,因为消费方锚定了契约。在无人使用的情况下保留它,意味着每个实现(以及未来的每个后端)都必须实现和测试一个无实际作用的方法。 ## 验证 -`has`/`delete`/`deleteStored` 已从持久化 seam、实现和契约测试套件中移除,没有新增无用导出;剩余操作(`create`/`append`/`load`/`list`)未受影响,ACP `session/list` 和崩溃恢复行为完全一致;seam README 和 `docs/architecture.md` 只列出存活的方法。 +`has`/`delete`/`deleteStored` 已从 persistence seam、实现和契约测试套件中移除,没有新增无用导出;剩余操作(`create`/`append`/`load`/`list`)未受影响,ACP `session/list` 和崩溃恢复行为完全一致;seam README 和 `docs/architecture.md` 仅列出存留的方法。 ## 后果 -- **`delete()` 是产品最终会需要的那类操作。** 确实如此——但「最终」正是关键。现在删除、等有真实消费方时再加回来,严格优于发布一份猜测的契约。双后端各自去掉了一个 `deleteStored` 实现,这是在本来不在范围内的包中的有限改动。 -- **低耦合。** 移除局限于持久化 seam + 实现 + 测试;没有跨包消费方引用被移除的方法,因此文档之外没有涟漪效应。 +- **`delete()` 是产品最终会需要的操作。** 确实如此,但「最终」正是关键。现在删除、将来基于真实消费方重新添加,严格优于发布一份猜测的契约。两个后端各自减少了一个 `deleteStored` 实现,这是在本次范围之外的包中的有限改动。 +- **低耦合。** 移除局限于 persistence seam + 实现 + 测试;没有跨包消费方引用被移除的方法,因此除文档外没有涟漪效应。 规模不大,但它将 seam 从「实现必须为无人提供什么」恢复为「恰好是消费方使用的东西」。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index dd5dd8e4e1..68ba79e73a 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-public-agent-stop-surface.md: 8c371911616b0a156156355b2ca15d795cfd21e5 -2026-06-20-public-agent-stop-surface.zh.md: 31deaa3649026a7579702e8e47edfdf05d2543ae +2026-06-20-public-agent-stop-surface.zh.md: bbd61fa1738fda64ec5e068dae84062163937c1a diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index 31deaa3649..bbd61fa173 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -1,39 +1,39 @@ # RFC:保留单一公开停止原语 -[English](2026-06-20-public-agent-stop-surface.md) | 中文 - Status: implemented +[English](2026-06-20-public-agent-stop-surface.md) | 中文 + > **实现说明:** 仅移除了 `abort()`。`whenIdle()` 予以保留,因为它是公开的静默信号,能安全处理等待者结算与替换轮次竞态;消费方不应从状态转换中自行重建该行为。 ## 问题 -公开的 `Agent` 句柄暴露了两种重叠的方式来停止进行中的工作:`abort(reason?)` 与 `cancel(reason?)`。`abort()` 仅终止当前正在执行的步骤,不影响队列中的工作;`cancel()` 清除队列中的工作与 steering(中途引导),终止正在运行的步骤,并处理步骤前竞态。在生产环境中,ACP(Agent Client Protocol)使用 `cancel()` 实现 `session/cancel`,而生命周期所有者通过 `AgentHandle.dispose()` 拆除 agent。没有生产调用方需要裸 `abort()`。 +公开的 `Agent` 句柄暴露了两种重叠的方式来停止进行中的工作:`abort(reason?)` 和 `cancel(reason?)`。`abort()` 仅终止当前步骤,不影响队列中的工作;`cancel()` 清除队列中的工作和 steering(中途引导)工作、中止正在运行的步骤,并处理步骤前竞态。在生产环境中,ACP(Agent Client Protocol)使用 `cancel()` 实现 `session/cancel`,而生命周期所有者通过 `AgentHandle.dispose()` 销毁 agent(智能体)。没有生产调用方需要裸 `abort()`。 -`abort()` 与 `cancel()` 的区别是真实存在的:`abort()` 保留队列中的提示词和 steering,而 `cancel()` 丢弃它们。但没有已发布的代码调用过公开的 `abort()` 动词。agent loop(智能体循环)自身的停止路径(`cancel()` 与 dispose)直接终止当前 `AbortController`,而非经由 `Agent.abort()` 路由。大多数调用 `abort()` 的测试实际上中断的是空队列,可以改用 `cancel(reason)`;那个刻意依赖队列保留的 steering 重投递测试则直接驱动进行中的 `AbortController`,因为 `cancel()` 会丢弃它试图证明在步骤终止后仍存活的队列 steering。无参 `abort()` 的默认原因(`'aborted'`)随动词一起删除,而非意外保留;`cancel()` 保留自己的默认值 `'cancelled'`。 +`abort()`/`cancel()` 的区别是真实存在的:`abort()` 保留队列中的提示词和 steering,而 `cancel()` 丢弃它们。但没有任何已上线的代码调用过公开的 `abort()` 动词。循环自身的停止路径(`cancel()` 和 disposal)直接中止当前 `AbortController`,而不经由 `Agent.abort()` 路由。大多数调用 `abort()` 的测试中断的是空队列,可以改用 `cancel(reason)`;那个刻意依赖队列保留的 steering 重投递测试则直接驱动进行中的 `AbortController`,因为 `cancel()` 会丢弃它试图证明在步骤中止后仍存活的已排队 steering。无参 `abort()` 的默认原因(`'aborted'`)随该动词一起删除,而非被意外保留;`cancel()` 保留自己的 `'cancelled'` 默认值。 -多余的公开接口面使 agent loop 不得不承载一个本质上是拆除内部机制的公开动词:`abort()` 必须被文档描述为与队列感知的取消不同,尽管 UI 取消几乎总是需要更广义的操作。 +多余的公开接口使得循环不得不承载一个本质上属于内部拆卸的公开动词:`abort()` 必须被文档描述为有别于队列感知的取消,尽管 UI 取消几乎总是需要更广泛的操作。 ## 决策 -`cancel()` 是 `Agent` 上唯一的公开*停止*原语。生命周期所有者使用 `AgentHandle.dispose()` 停止并注销 agent;非所有者使用 `cancel()` 放弃当前与队列中的工作。实现内部保留一个私有 abort controller,但它不属于面向插件的 `Agent` 契约。 +`cancel()` 是 `Agent` 上唯一的公开*停止*原语。生命周期所有者使用 `AgentHandle.dispose()` 停止并注销 agent;非所有者使用 `cancel()` 放弃当前和队列中的工作。实现内部保留一个私有的 abort controller,但它不属于面向插件的 `Agent` 契约。 -`whenIdle()` 作为公开的静默观测原语**予以保留**(agent 脱离 `running` 状态后 resolve;已处于 idle 时立即 resolve;dispose 后等待循环退出)。它不是停止动词;它是非所有者观测停止*完成*而无需 dispose agent 的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 拆除它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 +`whenIdle()` **保留**为公开的静默观测原语(agent 从 `running` 状态稳定后 resolve,已处于 idle 时立即 resolve,dispose 后等待循环退出)。它不是停止动词;它是非所有者在不 dispose agent 的前提下观测停止*完成*的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 销毁它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 -公开的 `abort()` 被删除,连同将其作为独立 API 测试的用例以及将步骤级终止描述为嵌入特性的文档。空队列终止测试迁移到 `cancel(reason)`,仍然验证取消行为;测试对象为 agent loop 内部 `AbortController` 的测试通过包内类型转换直接驱动该 controller 的私有字段;仅固定已移除的无参 `abort()` 默认值的测试随方法一起删除。disposer 仍为异步,仍等待循环停止。 +公开的 `abort()` 被删除,连同将其作为独立 API 测试的用例以及将步骤级中止描述为嵌入特性的文档。空队列中止测试迁移到 `cancel(reason)`,仍然验证取消行为;以循环内部 `AbortController` 为测试对象的用例通过包内类型转换直接驱动该 controller 的私有字段;仅固定已移除的无参 `abort()` 默认值的测试随方法一起删除。disposer 仍为异步,仍等待循环停止。 ## 曾考虑的替代方案 -**同时移除 `whenIdle()`**:最初提案的形态,在对照代码验证前提后被推翻(上方的实现说明记录了完整过程):它是承重的静默原语,强迫消费方手动观测 `running`→`idle` 转换正是防御性模式所警告的脆弱路径。 +**同时移除 `whenIdle()`**:最初提案的形态,在对照代码验证前提后被推翻(上方的实现说明记录了完整过程):它是承重的静默原语,迫使消费方手动观测 `running`→`idle` 转换正是防御性模式所警告的脆弱路径。 ## 验证 -`Agent` 不再暴露公开的 `abort()`,而 `cancel()`、`whenIdle()` 与 `steer()` 保留;ACP 取消调用 `cancel()`;拆除通过 handle disposal 等待静默,`whenIdle()` 为非所有者观测者在静默时 resolve;测试套件覆盖取消与 disposal 作为两条受支持的停止路径。 +`Agent` 不再暴露公开的 `abort()`,而 `cancel()`、`whenIdle()` 和 `steer()` 保留;ACP 取消调用 `cancel()`;拆卸通过 handle disposal 等待静默,`whenIdle()` 在静默时为非所有者观测者 resolve;测试套件覆盖取消和 disposal 作为两条受支持的停止路径。 ## 后果 -未来的插件无法通过公开接口仅终止当前模型/工具步骤而保留队列中的提示词。如果该用例变为现实需求,它应当带着一个具名消费方和更窄的契约重新引入。目前它只是把一个私有循环机制暴露为公开接口的潜在泛化。 +未来的插件无法通过公开接口仅中止当前模型/工具步骤而保留队列中的提示词。如果该用例变为现实需求,它应当带着一个具名消费方和更窄的契约回归。目前它是将私有循环机制保持公开的潜在泛化。 ## 相关 -本 RFC 仅移除冗余的停止动词。中途 steering 仍是有意保留的消息路径;静默观测仍通过 `whenIdle()` 提供。最终的公开接口面为 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 与 identity。 +本 RFC 仅移除冗余的停止动词。中途 steering 仍是有意保留的消息路径;静默观测仍通过 `whenIdle()` 提供。最终的公开接口为 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 和 identity。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 4f09cbbc3b..ec11d8ea9a 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-agent-boundary-mirror-events.md: 46b5e43951885915d4c3dd3f867ced6c31d32035 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: dd6952ec8923c17d703fc6850197bef09b1c0ee7 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 15be07f43997b1d899f0297d311c3ad83f088ee0 diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index dd6952ec89..15be07f439 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -1,32 +1,32 @@ # RFC:停止将持久化边界镜像为 agent 事件 -Status: implemented - [English](2026-06-20-remove-agent-boundary-mirror-events.md) | 中文 +Status: implemented + ## 问题 -agent loop(智能体循环)曾通过可回放的 `SessionEvent` 日志和实时 `agent/*` 镜像两条路径暴露持久化的轮次与步骤边界。消费方不得不在两个表达同一事实的来源之间做选择,并协调二者的时序。ACP(Agent Client Protocol)和持久化层已经使用事件日志;stdio UI 是唯一仍在消费镜像事件的组件,而它也已经从 `session/event` 渲染工具调用和工具结果。 +agent loop(智能体循环)通过可回放的 `SessionEvent` 日志和实时 `agent/*` 镜像两条路径暴露持久化的轮次与步骤边界。消费方不得不在同一事实的两个来源之间做选择,并协调二者的时序。ACP(Agent Client Protocol)和持久化层已经使用日志;stdio UI 是唯一仍在消费镜像的组件,而它已经从 `session/event` 渲染工具调用和工具结果。 -这种重复并非零成本。每次生命周期变更都要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法的位置可插入,只能带外报告。 +这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 ## 决策 -让 `session/event` 成为唯一的实时边界/transcript(文本记录)流。需要渲染轮次、工具调用、工具结果、助手消息和持久化边界的消费方统一订阅 `session/event`,从持久化层使用的同一套事件词汇派生 UI。 +将 `session/event` 作为唯一的实时边界/transcript(文本记录)流。需要渲染轮次、工具调用、工具结果、助手消息和持久化边界的消费方统一订阅 `session/event`,从持久化层使用的同一套事件词汇中派生 UI。 -移除 `agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。边界消费方改为订阅 `session/event`。需要 agent 标签的 UI 通过 `agent/created` 和 `agent/disposed` 维护一份 session 到 agent 的映射,因为持久化的 `turn/start` 携带轮次编号但不携带 agent id。 +移除 `agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。边界消费方改为订阅 `session/event`。如果 UI 需要 agent 标签,则通过 `agent/created` 和 `agent/disposed` 维护一份 session 到 agent 的映射,因为持久化的 `turn/start` 携带轮次编号但不携带 agent id。 -步骤镜像没有消费方,已由 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 率先移除。该决策保留了轮次镜像供 stdio UI 使用;本 RFC 在将测试 REPL 迁移到 `session/event` 加 id 映射之后,将轮次镜像也一并移除。 +步骤镜像已无消费方,由 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 先行移除。该决策保留了轮次镜像供 stdio UI 使用;本 RFC 在将测试 REPL 迁移到 `session/event` 加 id 映射之后,将轮次镜像也一并移除。 ## 范围:移除什么、不移除什么 -本决策仅涉及持久化的轮次与步骤边界。`agent/steering` 镜像的是一条控制记录,`agent/stream-chunk` 镜像的是 token 流,因此各自单独处理:见 [steering](2026-07-04-remove-agent-steering-mirror.md) 和 [stream chunks](2026-07-02-remove-stream-chunk-mirror.md)。`agent/created`、`agent/disposed`、`agent/status`、`agent/error` 和 `agent/queued` 仍作为实时生命周期或控制事件保留,而非 transcript 镜像;排队的输入可能在任何持久化事件产生之前就被取消。 +本决策仅涉及持久化的轮次与步骤边界。`agent/steering` 镜像的是一条控制记录,`agent/stream-chunk` 镜像的是 token 流,因此各自单独处理:[steering](2026-07-04-remove-agent-steering-mirror.md) 与 [stream chunks](2026-07-02-remove-stream-chunk-mirror.md)。`agent/created`、`agent/disposed`、`agent/status`、`agent/error` 和 `agent/queued` 仍作为实时生命周期或控制事件保留,而非 transcript 镜像;排队中的输入可能在任何持久化事件产生之前就被取消。 ## 曾考虑的替代方案 -- **在同一个变更中移除 `agent/steering`**:否决,因为它镜像的是控制记录而非边界。 -- **为 stdio UI 保留轮次镜像**:否决,因为 UI 可以渲染 `session/event` 并从 id 映射中恢复 agent 标签。 +- **在同一个变更中一并移除 `agent/steering`**:否决,因为它是控制记录的镜像而非边界镜像。 +- **为 stdio UI 保留轮次镜像**:否决,因为 UI 可以渲染 `session/event` 并通过 id 映射恢复 agent 标签。 ## 后果 -插件不再能从便捷的 `Agent` 优先事件中观察轮次/步骤边界。它必须订阅 `session/event` 或自行维护 session 到 agent 的关联。这是可接受的取舍:边界消费方不应依赖一条可能与持久化日志产生漂移的第二事件源。 +插件不再能从便捷的 `Agent` 优先事件中观察轮次/步骤边界,必须订阅 `session/event` 或自行维护 session 到 agent 的关联。这是可接受的取舍:边界消费方不应依赖一条可能与持久化日志产生漂移的第二事件源。 diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml index f096620016..aecab4f6b5 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-26-fsspec-style-fs-seam.md: 493af9341177aaaed4cdca03a3c20f326c5c4dac -2026-06-26-fsspec-style-fs-seam.zh.md: ba6a366990f749c5fb84e30142965dc5a2d1d0b7 +2026-06-26-fsspec-style-fs-seam.zh.md: 4aa9b260396c22433ea9a4c9af0b4101fa6895e7 diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md index ba6a366990..4aa9b26039 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md @@ -1,21 +1,21 @@ -# RFC:拆分文件系统 seam——提供方文本变更与 `dsh-fs-policy` 插件 - -Status: implemented +# RFC:拆分文件系统 seam——提供方文本变更操作与 `dsh-fs-policy` 插件 [English](2026-06-26-fsspec-style-fs-seam.md) | 中文 +Status: implemented + ## 问题 -[filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 引入的文件系统能力目前让一个抽象 `FileSystem` 服务同时承担两类职责: +[filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 中引入的文件系统能力目前让一个抽象的 `FileSystem` 服务承担两类不同的职责: -1. **提供方操作**——解析目标、stat/版本元数据、文本读取/流式读取、原子写入,以及带守卫的字面编辑。 -2. **面向 agent 的策略**——行窗口、字面编辑语义,以及读后写/编辑的 observed-state。 +1. **提供方操作**——解析目标、stat/版本元数据、文本读取/流式读取、原子写入,以及受保护的字面编辑。 +2. **面向 agent(智能体)的策略**——行窗口、字面编辑语义,以及读后写/编辑的观测状态。 -这导致每个未来的后端都要重新实现面向模型的读取语义和观测策略。`readPage` 返回带行号的行和视图元数据;基类服务按 owner 存储文件状态,并区分 `full` 与 `partial` 读取。这些是有用的策略,但它们不是文件系统提供方的原语。字面文本变更则不同:版本守卫、字面匹配、歧义检测与原子重写必须在提供方变更边界内保持一体,但当前的 `applyEdit` 命名及其周围的 seam 把这个提供方操作绑定到了旧的读后编辑策略形状上。 +这导致每个未来的后端都要重新实现面向模型的读取语义和观测策略。`readPage` 返回带行号的行和视图元数据;基础服务按 owner 存储文件状态,并区分 `full` 与 `partial` 读取。这些是有用的策略,但它们不是文件系统提供方的原语。字面文本变更则不同:版本守卫、字面匹配、歧义检测与原子重写必须留在提供方的变更边界内,但当前的 `applyEdit` 命名及其周围的 seam 将这一提供方操作绑定到了旧的读后编辑策略形状上。 -这还造成了一个真实的 UX 死胡同:窗口化读取记录 `view: partial`,而 partial 视图无法授权 `edit`。一个模型读取了大文件的第 100-150 行,除非先获得一次 `full` 读取,否则无法编辑第 120 行——而对于超过读取上限的文件,full 读取可能不可行。字面编辑真正需要的只是新鲜度:被匹配的字节必须仍来自模型所读的那个版本。 +这还造成了一个真实的用户体验死胡同:窗口化读取记录 `view: partial`,而 partial 视图无法授权 `edit`。一个模型读取了大文件的第 100-150 行,如果想编辑第 120 行,就必须先获取一次 `full` 读取,而对于超过读取上限的文件这可能做不到。字面编辑实际上只需要新鲜度:被匹配的字节仍然来自模型所读取的那个版本即可。 -旧 RFC 已经推迟了独立的 `@deepseek-ai/dsh-fs-policy` 包。本 RFC 构建该层,并让 `ctx.fs` 贴近 fsspec 风格的存储原语(`info`/`cat`/`open`),但不将其变成完整的 fsspec。 +旧 RFC 已经推迟了独立的 `@deepseek-ai/dsh-fs-policy` 包(package)。本 RFC 构建该层,并让 `ctx.fs` 贴近 fsspec 风格的存储原语(`info`/`cat`/`open`),但不将其变成完整的 fsspec。 ## 决策 @@ -28,13 +28,13 @@ provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (op provider dsh-fs-local local implementation of ctx.fs ``` -`dsh-tool-fs` 保持相同的面向模型的 `read`/`write`/`edit` schema。它是执行器:注入 `fs`(不是策略服务)并直接访问 `ctx.fs`,拥有读取窗口化逻辑,并派发 `fs/*` 事件以便 `dsh-fs-policy` 进行门控和记录。 +`dsh-tool-fs` 保持相同的面向模型的 `read`/`write`/`edit` schema。它是执行器:注入 `fs`(不是策略服务)并直接访问 `ctx.fs`,拥有读取窗口化逻辑,并分发 `fs/*` 事件以便 `dsh-fs-policy` 进行门控和记录。 本 RFC 决定了四层拆分、提供方契约和新鲜度策略。工具↔策略的**耦合方式**随后由[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 细化:`dsh-fs-policy` 是一个门控**插件**,通过 `fs/*` 事件参与而非提供 `ctx.fileContext` 方法服务,因此工具不与它产生方法耦合,读取窗口化与 fs I/O 留在 `dsh-tool-fs` 中。本文描述的是最终落地的事件门控形态;提供方的版本守卫是可选的(省略 = 无条件裸提供方)。 ## 提供方契约 -`@deepseek-ai/dsh-fs` 收缩为提供方文本 IO 加带守卫的文本变更: +`@deepseek-ai/dsh-fs` 收缩为提供方文本 IO 加受保护的文本变更: ```ts ignore-check abstract resolve(path: string): Promise<FsTarget> @@ -55,76 +55,76 @@ type FsWriteIntent = | { kind: 'replaceIfVersion'; version: FsVersion } ``` -`stat` 返回元数据而非内容。`version` 是新鲜度令牌;`type` 让执行器在读取前拒绝目录/特殊文件;`size` 让 `read` 工具无需通过失败来探测即可选择 `readText` 还是 `streamText`。返回 `undefined` 表示目标不存在。 +`stat` 返回元数据而非内容。`version` 是新鲜度令牌;`type` 让执行器在读取前拒绝目录/特殊文件;`size` 让 `read` 工具无需通过失败探测即可选择 `readText` 还是 `streamText`。`undefined` 表示目标不存在。 `readText` 读取整个常规文本文件。`streamText` 以相同的文本语义流式读取大文件。两个提供方原语负责常规文件检查、UTF-8 解码、二进制/NUL 拒绝以及 `FS_NOT_TEXT`;策略层从不处理原始字节,也不重新实现跨分片解码。`readText` 是小文件/直接全文件原语,而面向模型的大文件读取使用 `streamText`。 -`writeText` 是原子性的临时文件 + rename,带有显式的写入意图。`createIfAbsent` 创建不存在的目标,对已存在的目标以 `FS_NOT_OBSERVED` 拒绝;这是 owner 没有先前读取时使用的路径。`replaceIfVersion` 仅在目标以观测到的版本存在时替换;目标不存在或版本不匹配时抛出 `FS_STALE_VERSION`。 +`writeText` 是原子的临时文件 + rename,带有显式的写入期望。`createIfAbsent` 创建不存在的目标,对已存在的目标以 `FS_NOT_OBSERVED` 拒绝;这是 owner 没有先前读取时使用的路径。`replaceIfVersion` 仅在目标以观测到的版本存在时替换;目标不存在或版本不匹配时抛出 `FS_STALE_VERSION`。 -`editText` 是提供方级别的带守卫文本变更。启用守卫时,它先验证目标仍以 `expected.version` 存在,然后读取当前文本、应用字面替换并原子写入。陈旧检查必须在字面匹配之前发生,这样基于旧读取的编辑会报告 `FS_STALE_VERSION`,而不是对更新内容做匹配后报告 `FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`。将此原语保留在提供方 seam 上,保持了后端本地锁定能力,也让未来的远程后端可以实现原生的 compare-and-edit 而无需策略层拉取整个文件。 +`editText` 是提供方级别的受保护文本变更。启用守卫时,它首先验证目标仍以 `expected.version` 存在,然后读取当前文本、应用字面替换并原子写入。过期检查必须在字面匹配之前发生,这样基于旧读取的编辑会报告 `FS_STALE_VERSION`,而不是对更新内容进行匹配后报告 `FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`。将此原语保留在提供方 seam 上,保持了后端本地锁定的能力,也让未来的远程后端能够实现原生的 compare-and-edit,而无需策略层拉取整个文件。 -这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半层。UTF-8 解码、二进制/NUL 拒绝、带守卫的全文件写入和带守卫的字面文本编辑都在提供方内完成,使策略层从不接触原始字节、不重新实现跨分片解码、也不将陈旧检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、observed-state 存储都不会泄漏下去。 +这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半个层次。UTF-8 解码、二进制/NUL 拒绝、受保护的全文件写入和受保护的字面文本编辑都在提供方内完成,因此策略层从不接触原始字节、不重新实现跨分片解码、也不将过期检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、观测状态存储都不会泄漏下去。 -从 `dsh-fs` 中删除:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody`,以及 observed-state `WeakMap`。`applyEdit` 被更窄的提供方原语 `editText` 取代,后者的契约是版本守卫的字面文本变更,而非策略层的读取授权。`FS_PARTIAL_OBSERVATION` 错误码也从 `FsErrorCode` 分类体系中移除:新鲜度授权没有 partial/full 之分,因此没有任何场景会抛出它。`FsTargetKey` 和 `FsVersion` 按照既有的 [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md) 成为品牌化的不透明 id。 +从 `dsh-fs` 中删除的内容:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody`,以及观测状态 `WeakMap`。`applyEdit` 被更窄的提供方原语 `editText` 取代,后者的契约是版本守卫的字面文本变更,而非策略层的读取授权。`FS_PARTIAL_OBSERVATION` 错误码也从 `FsErrorCode` 分类体系中移除:新鲜度授权没有 partial/full 之分,因此没有什么能触发它。`FsTargetKey` 和 `FsVersion` 按照既有的 [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md) 成为品牌化的不透明 id。 ## 策略契约 -`@deepseek-ai/dsh-fs-policy` 是一个插件而非服务:它不注册任何 `ctx.*` 键,也不注入任何东西。它拥有写入/编辑新鲜度策略和 observed-state——这些不属于 `FileSystem` 提供方基类(否则沙箱化/远程后端会继承它无需承担的面向模型的观测策略)。它通过执行器派发的 `fs/*` 事件门控来贡献这些策略。(本 RFC 最初提出了一个具体的 `ctx.fileContext` 方法服务,带 `read`/`write`/`edit` 方法;[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 将其细化为此处描述的门控插件,使工具从不与策略产生方法耦合。) +`@deepseek-ai/dsh-fs-policy` 是一个插件,不是服务:它不注册任何 `ctx.*` 键,也不注入任何东西。它拥有写入/编辑新鲜度策略和观测状态,这些不属于 `FileSystem` 提供方基类(否则沙箱/远程后端会继承它无需承载的面向模型的观测策略)。它通过执行器分发的 `fs/*` 事件门控贡献该策略。(本 RFC 最初提出了一个具体的 `ctx.fileContext` 方法服务,带有 `read`/`write`/`edit` 方法;[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 将其改造为此处描述的门控插件,使工具从不与策略产生方法耦合。) -Observed state 以 `WeakMap<owner, Map<targetKey, FsVersion>>` 形式存在于此。当且仅当 owner 读取、写入或编辑过该目标时条目才存在(每次成功都会发出 `fs/observed`),因此条目的存在*本身就是*先前观测记录——没有单独的 `hasRead` 标志。owner 从不透明的事件 actor(`{ agent?: { session? } }`)结构化派生,该形状定义在 `dsh-fs-policy` 中而非 `dsh-fs` 中。 +观测状态以 `WeakMap<owner, Map<targetKey, FsVersion>>` 的形式存放于此。当且仅当 owner 读取、写入或编辑过该目标时,条目才存在(每次成功都会发出 `fs/observed`),因此条目的存在*本身就是*先前观测的记录——没有单独的 `hasRead` 标志。owner 从不透明的事件 actor(`{ agent?: { session? } }`)结构化派生,该形状定义在 `dsh-fs-policy` 中而非 `dsh-fs` 中。 该插件决定三个 `fs/*` 事件: - `fs/write-intent`——无先前观测 ⇒ `{ kind: 'createIfAbsent' }`(只有新文件可以盲创建);有先前观测 ⇒ `{ kind: 'replaceIfVersion', version: vObserved }`(已有文件仅在自观测以来未变时才替换)。单槽决策;不调用 `next()`。 -- `fs/edit-intent`——要求 owner 有先前观测(否则 `FS_NOT_OBSERVED`);返回 `{ version: vObserved }` 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一个赢/一个陈旧。 -- `fs/observed`——在成功的读取/写入/编辑后为该 owner+target 记录 `{ version }`。同步、仅副作用的 `WeakMap.set`。 +- `fs/edit-intent`——要求 owner 有先前观测(否则 `FS_NOT_OBSERVED`);返回 `{ version: vObserved }` 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一赢一过期。 +- `fs/observed`——在成功的读取/写入/编辑后,为该 owner+target 记录 `{ version }`。同步、仅副作用的 `WeakMap.set`。 -该插件不做任何文件系统 I/O:「你是否观测过这个文件?」是一次 `WeakMap` 查找,而「你读到的版本是否仍然是当前版本?」在 `ctx.fs.editText`/`writeText` 内部的同一原子锁中决定(该锁同时执行变更)——插件只提供 `vObserved` 作为基础。 +该插件不做任何文件系统 I/O:「你是否观测过此文件?」是一次 `WeakMap` 查找,而「你读取的版本是否仍然是当前版本?」在 `ctx.fs.editText`/`writeText` 内部、与执行变更相同的原子锁中决定——插件只提供 `vObserved` 作为基础。 ## 工具契约 -`dsh-tool-fs` 保持相同的 schema 和提示词表面。`read` 仍暴露 `file_path`、`offset` 和 `limit`;`write` 和 `edit` 不变。它是执行器:验证模型参数,通过 `ctx.fs` 直接读取/写入/编辑,拥有行窗口化和结果渲染(`N: text`、页脚、`<path>/<content>` 信封),并派发 `fs/*` 事件。 +`dsh-tool-fs` 保持相同的 schema 和提示词表面。`read` 仍然暴露 `file_path`、`offset` 和 `limit`;`write` 和 `edit` 不变。它是执行器:验证模型参数,通过 `ctx.fs` 直接读取/写入/编辑,拥有行窗口化和结果渲染(`N: text`、页脚、`<path>/<content>` 信封),并分发 `fs/*` 事件。 -每次变更先派发其 intent waterfall(瀑布式事件)并以 `undefined` 作为裸提供方默认值,然后调用 `ctx.fs`,再发出 `fs/observed`:例如 `write` 执行 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`。`read` 做一次 stat、读取/流式读取、构建窗口,然后发出 `fs/observed`。将 `exec` 作为 actor 传入,让 `dsh-fs-policy` 无需工具深入策略即可派生 owner。 +每个变更操作先分发其 intent waterfall(瀑布式事件),带有 `undefined` 裸提供方默认值,然后调用 `ctx.fs`,再发出 `fs/observed`。例如 `write` 执行 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`。`read` 先 stat 一次,然后读取/流式读取,构建窗口,最后发出 `fs/observed`。将 `exec` 作为 actor 传递,让 `dsh-fs-policy` 无需工具深入策略即可派生 owner。 -由于策略通过带 `undefined` 默认值的事件贡献,`dsh-tool-fs` 不与 `dsh-fs-policy` 产生方法耦合:插件不存在时,每个 intent waterfall 落入 `undefined`(无条件裸提供方写入/编辑),`fs/observed` 无监听者。加载插件后即叠加读后写/编辑策略。 +由于策略通过带有 `undefined` 默认值的事件贡献,`dsh-tool-fs` 不与 `dsh-fs-policy` 产生方法耦合:在插件缺席时,每个 intent waterfall 都落到 `undefined`(无条件裸提供方写入/编辑),`fs/observed` 没有监听器。加载插件后即可叠加读后写/编辑策略。 ## 并发边界 -进程内更新是安全的:本地后端保持既有的按目标变更锁,因此版本检查-然后-rename 是串行化的,失败的更新看到 `FS_STALE_VERSION`。 +进程内更新是安全的:本地后端保持既有的按目标变更锁,因此版本检查-然后-rename 是串行化的,失败的更新会看到 `FS_STALE_VERSION`。 -进程内创建由同一按目标变更锁守卫:两个调用者以 `createIfAbsent` 竞争时串行化,一个创建成功,下一个看到目标已存在并收到 `FS_NOT_OBSERVED`。跨进程创建仅尽力而为;本地的 stat-then-rename 守卫无法在所有未来后端上提供可移植的排他创建保证。 +进程内创建由同一个按目标变更锁保护:两个调用者以 `createIfAbsent` 竞争时串行化,一个创建成功,另一个看到目标已存在并收到 `FS_NOT_OBSERVED`。跨进程创建仅为尽力而为;本地的 stat-then-rename 守卫无法在所有未来后端上提供可移植的排他创建保证。 -跨进程写入是尽力新鲜度加原子替换:`mtime:size` 通常能捕获编辑器保存,但同一时刻相同大小的写入可能遗漏;原子性的 temp+rename 防止文件撕裂但不能防止所有丢失更新。 +跨进程写入是尽力而为的新鲜度加原子替换:`mtime:size` 通常能捕获编辑器保存,但同一 tick 相同大小的写入可能遗漏;原子的 temp+rename 防止文件撕裂但不能防止所有丢失更新。 ## 取代 -本 RFC 逆转了 [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 的两项决策,并收窄了第三项: +本 RFC 逆转了 [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 中的两项决策,并收窄了第三项: -- 读后写/编辑策略从 `ctx.fs` 移出,进入 `dsh-fs-policy` 插件(在 `fs/*` 事件门控上)。 +- 读后写/编辑策略从 `ctx.fs` 移出,进入 `dsh-fs-policy` 插件(通过 `fs/*` 事件门控)。 - 文本读取不再返回后端编号的行记录或 `full`/`partial` 视图;授权基于版本新鲜度,因此窗口化读取在文件未变时即可授权编辑。 -- 字面编辑不再位于旧的 `applyEdit` API 之后(该 API 混合了后端变更与 seam 拥有的观测策略)。它作为 `editText` 保留为提供方原语,因为版本守卫 + 字面匹配 + 原子重写必须在提供方的变更临界区内保持一体,以确保正确的错误归因和并发行为。 +- 字面编辑不再位于旧的 `applyEdit` API 之后(该 API 混合了后端变更与 seam 拥有的观测策略)。它作为 `editText` 保留为提供方原语,因为版本守卫 + 字面匹配 + 原子重写必须留在提供方的变更临界区内。 保留的内容:接口/实现/消费方纪律、消费方不导入后端规则、后端定义的 target/version/display 元数据、原子本地写入,以及共享的 `FsError` 分类体系。 ## 验证 -`dsh-fs` 精确暴露 `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`(`stat` 返回 `FsInfo | undefined`,`writeText` 接受 `FsWriteIntent`),已删除的类型/原语不再存在;`dsh-fs-local` 不携带行、视图或 `formatReadBody` 逻辑;面向模型的 schema 逐字节未变。测试固定了以下行为:窗口化读取可以授权对未变文件的后续编辑;基于陈旧读取的编辑在尝试字面匹配之前报告 `FS_STALE_VERSION`;版本 CAS 行为得到保持;观测契约成立(通过 `read` 工具的读取记录 observed-state;直接的 `ctx.fs` 读取不记录);`dsh-fs-policy` 具有 HMR/dispose 覆盖率。 +`dsh-fs` 精确暴露 `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`(`stat` 返回 `FsInfo | undefined`,`writeText` 接受 `FsWriteIntent`),已删除的类型/原语不再存在;`dsh-fs-local` 不包含行、视图或 `formatReadBody` 逻辑;面向模型的 schema 保持逐字节不变。测试固定了以下行为:窗口化读取授权对未变文件的后续编辑;基于过期读取的编辑在尝试字面匹配之前报告 `FS_STALE_VERSION`;版本 CAS 行为得以保留;观测契约成立(`read` 工具的读取记录观测状态;直接 `ctx.fs` 读取不记录);`dsh-fs-policy` 具有 HMR(热模块替换)/dispose(资源释放)覆盖率。 ## 后续扩展 -该 seam 后来由 [Add direct directory listing to the filesystem seam](../architecture/2026-07-03-filesystem-directory-listing-seam.md) 扩展了直接目录列表功能。该后续工作单独跟踪,以使本 RFC 的验收标准继续描述最初交付的 fsspec 风格改造。 +该 seam 后来由 [Add direct directory listing to the filesystem seam](../architecture/2026-07-03-filesystem-directory-listing-seam.md) 扩展了直接目录列表功能。该后续工作单独跟踪,以使本 RFC 的验收标准继续描述最初交付的 fsspec 风格重构。 ## 曾考虑的替代方案 -- **字节级 fsspec(`cat`/`open` 返回原始字节)**——否决:该 seam 刻意定位为文本存储,比字节级高半层,使 UTF-8 解码、二进制/NUL 拒绝和带守卫的文本变更在提供方内只实现一次,策略层从不接触原始字节,也不将陈旧检查与变更临界区分离。 -- **具体的 `ctx.fileContext` 方法服务**——本 RFC 最初的策略形态;由[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 改造为门控插件,使工具从不与策略产生方法耦合。 -- **将 `readPage` 和 `full`/`partial` 视图授权保留在提供方上**——改造前的形态,即「取代」一节所逆转的内容:视图完整性不是编辑安全所需的信号,版本新鲜度才是;视图规则使超过读取上限的大文件无法编辑。 +- **字节级 fsspec(`cat`/`open` 返回原始字节)**:否决。该 seam 刻意定位为文本存储,比字节级高半个层次,这样 UTF-8 解码、二进制/NUL 拒绝和受保护的文本变更只在提供方实现一次,策略层从不接触原始字节,也不将过期检查与变更临界区分离。 +- **具体的 `ctx.fileContext` 方法服务**:本 RFC 最初的策略形态;被[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 改造为门控插件,使工具从不与策略产生方法耦合。 +- **在提供方保留 `readPage` 和 `full`/`partial` 视图授权**:「取代」一节所逆转的重构前形态。视图完整性不是编辑安全所需的,版本新鲜度才是;而视图规则使超过读取上限的大文件无法编辑。 ## 后果 - 新增第四个 fs 包和一个新的插件层。这是有意为之:它是此前推迟的策略层,而非第二个抽象后端 seam。 -- 直接使用 `ctx.fs` 会绕过策略:直接的 `ctx.fs.readText` 不发出 `fs/observed`,因此在默认策略下,后续的 `edit` 会以 `FS_NOT_OBSERVED` 拒绝,直到通过 `read` 工具读取该文件。该失败是显式且有文档记录的。 -- 大文件行窗口化从后端移至 `dsh-tool-fs` 中的 `read` 工具;文本解码和二进制拒绝留在 `ctx.fs.streamText` 中,因此这只是窗口化逻辑的迁移,不是第二套文本 IO 实现。 -- 将 `editText` 保留在提供方 seam 上意味着每个后端都必须实现字面替换契约。这是有意为之:该操作不是纯存储,但陈旧守卫 + 字面匹配 + 原子重写是必须保持一体的单元,以确保正确的错误归因和并发行为。该契约应保持窄且仅限文本,以便未来后端可以原生实现或通过全文件重写实现。 -- 新鲜度允许在窗口化读取后执行全文件 `write`。这比旧的视图检查更弱,但避免了大文件无法编辑的问题;提示词引导仍然不鼓励盲目的全文件替换。 +- 直接使用 `ctx.fs` 会绕过策略:直接 `ctx.fs.readText` 不发出 `fs/observed`,因此在默认策略下,后续 `edit` 会以 `FS_NOT_OBSERVED` 拒绝,直到通过 `read` 工具读取该文件。这一失败是显式且有文档记录的。 +- 大文件行窗口化从后端移至 `dsh-tool-fs` 中的 `read` 工具;文本解码和二进制拒绝留在 `ctx.fs.streamText` 中,因此这只是窗口化逻辑的迁移,而非第二套文本 IO 实现。 +- 将 `editText` 保留在提供方 seam 上意味着每个后端都必须实现字面替换契约。这是有意为之:该操作不是纯存储,但过期守卫 + 字面匹配 + 原子重写是必须保持在一起的单元,以确保正确的错误归因和并发行为。该契约应保持窄且仅限文本,以便未来后端可以原生实现或通过全文件重写实现。 +- 新鲜度允许在窗口化读取后进行全文件 `write`。这比旧的视图检查更弱,但避免了大文件无法编辑的问题;提示词引导仍然不鼓励盲目的全文件替换。 diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index ca87af6f9a..a9b8b58f67 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-remove-stream-chunk-mirror.md: 1ec633b09c8e53ae7145a49061aced191a9aa765 -2026-07-02-remove-stream-chunk-mirror.zh.md: 83674658d24621b12a866262bb58dde166bedf2d +2026-07-02-remove-stream-chunk-mirror.zh.md: 7cf8a5d056c1bb4193263c58a8e4258173fe8b4e diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index 83674658d2..7cf8a5d056 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -1,12 +1,12 @@ # RFC:停止将 token 流镜像为 agent 事件 -Status: implemented - [English](2026-07-02-remove-stream-chunk-mirror.md) | 中文 +Status: implemented + ## 问题 -agent loop(智能体循环)将模型的每个 token 增量同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/loop.ts` 中,两者仅相隔一行: +agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/loop.ts` 中,二者仅相隔一行: ```ts ignore-check const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) @@ -17,17 +17,17 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror - 持久事件:`assistant/chunk: { turn, step, chunk }`。 - 实时发射:`agent/stream-chunk(agent, turn, step, chunk)`——相同的 `StreamChunk`,相同的 `turn`/`step`。 -实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 +实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方直接丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 -这与[边界镜像移除](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复如出一辙:消费方对同一个持久事实有两个真源,每次修改都必须同时触及两处。那份 RFC 将分片流推迟处理(「`assistant/chunk` 的持久化仍然是承重的,因此分片流后续可以作为镜像来评估,但那是一个独立的决策」),而非一并打包。本 RFC 就是那个独立的决策。 +这与[边界镜像移除](2026-06-20-remove-agent-boundary-mirror-events.md)为 turn/step 边界消除的重复如出一辙:消费方对同一个持久事实有两个真源,每次变更都要同时修改两处。那份 RFC 将 chunk 流推迟处理(「`assistant/chunk` 的持久化仍然是承重的,因此 chunk 流后续可以作为镜像来评估,但那是一个独立决策」),而非一并纳入。本 RFC 即是那个独立决策。 -推迟所依赖的前提已经尘埃落定:分片持久化是权威的,且将保留。停止持久化分片、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 流。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 +推迟所依赖的前提已经明确:chunk 持久化是权威的,且将保留。停止持久化 chunk、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 ## 决策 -从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化和回放已经使用的正是同一条流。`session/event` 是唯一的实时 transcript(文本记录)流(assistant 分片、轮次/步骤边界、工具活动、todo)。 +从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化与回放已经使用的正是同一个序列。`session/event` 是唯一的实时 transcript(文本记录)流(assistant chunk、turn/step 边界、工具活动、todo)。 -**消费方。** 唯一重要的生产消费方——ACP 桥接层(`dsh-acp`,真正面向编辑器的流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其分片渲染被折叠进该监听器的 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立的监听器(`agent/stream-chunk` 和 `session/event`)之间共享,分片与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 +**消费方。** 唯一重要的生产消费方——ACP 桥接(`dsh-acp`,面向编辑器的真实流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其 chunk 渲染被折叠进该监听器作为 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立监听器(`agent/stream-chunk` 和 `session/event`)之间共享,chunk 与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 ## 范围 @@ -35,13 +35,13 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror 未触及: - `assistant/chunk`(持久会话事件)——权威的 token 流,原样保留。本 RFC 移除的是实时镜像,而非持久化(持久化移除提案已被单独否决,见上文)。 -- `agent/steering`——本决策未触及(它是控制信号,不是 token 流)。其持久孪生事件是 `steering/message`,镜像发射由其自己的后续 RFC 移除:[移除 `agent/steering` 镜像发射](2026-07-04-remove-agent-steering-mirror.md)。 +- `agent/steering`——本决策未触及(它是控制信号,不是 token 流)。其持久孪生事件是 `steering/message`,镜像发射由其自身的后续 RFC 移除:[移除 `agent/steering` 镜像发射](2026-07-04-remove-agent-steering-mirror.md)。 - `agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`、`agent/session-start`——生命周期/控制事件,不是 transcript 数据,也没有持久副本。 ## 曾考虑的替代方案 -**移除持久化、仅保留瞬态实时流**——反向裁剪,已被[单独否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md):高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 流。这一点既已确定,实时发射就是配对中冗余的那一半。 +**移除持久化、仅保留瞬态实时流**——反向裁剪,已被[单独否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md):高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。在此前提确定后,实时发射才是配对中冗余的那一半。 ## 后果 -插件不再能从以 `Agent` 为首参的事件观察 token 增量。它应订阅 `session/event` 并过滤 `assistant/chunk`(如需 `Agent` 句柄,可从 `agent/created`/`agent/disposed` 构建的 session-id→agent 映射中恢复,与边界消费方的做法完全一致)。没有任何生产消费方在分片时需要实时的 `Agent`;这与边界镜像移除所做的权衡完全相同,是可接受的。 +插件不再能通过以 `Agent` 为首参的事件观察 token delta。它需要订阅 `session/event` 并过滤 `assistant/chunk`(如需 `Agent` 句柄,可通过 `agent/created`/`agent/disposed` 构建的 session-id→agent 映射恢复,与边界消费方已有的做法完全一致)。没有任何生产消费方在 chunk 时需要实时的 `Agent`;这与边界镜像移除所做的权衡相同,是可接受的。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml index 776f41a858..9b195da18b 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-drop-image-content-block.md: 8222880b61c225c39a1e132353c5f343fb90cb4b -2026-07-04-drop-image-content-block.zh.md: d1379d69a0c5056cdfcc744182cd9b6c52f222d5 +2026-07-04-drop-image-content-block.zh.md: cb9372e50863193cd579c0bf8de991810db95206 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md index d1379d69a0..cb9372e508 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md @@ -1,29 +1,29 @@ # RFC:移除 `image` 内容块,直到有路径能真正处理它 -Status: implemented - [English](2026-07-04-drop-image-content-block.md) | 中文 +Status: implemented + ## 问题 -`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其**丢弃**:DeepSeek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP 编解码器既不声明 image prompt 能力、也不向外转发 image 块,并且对入站的 image prompt 内容直接**拒绝**;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇表声明了一种没有任何路径兑现的能力,这正是 `AGENTS.md` 防御性模式所警告的静默数据丢失形态。唯一的构造点是用于固定 skip/drop/estimate 分支的测试。 +`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其**丢弃**:deepseek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP 编解码器既不宣告 image prompt 能力、也不向外转发 image 块,并且会拒绝入站的 image prompt 内容;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 ## 决策 -移除 `ImageBlock`、其 map 条目,以及适配器、ACP 渲染和压缩中的 image 专用分支。在同一个变更中更新所属词汇文档与生成的引用。未知的扩展块仍然覆盖 default 分支,ACP 继续独立于 harness 词汇拒绝入站 image prompt 内容。 +移除 `ImageBlock`、其 map 条目,以及适配器、ACP 渲染和压缩中的 image 专用分支。在同一个变更中更新所属的词汇文档与生成的引用。未知扩展块仍然覆盖默认分支,ACP 继续独立于 harness 词汇拒绝入站的 image prompt 内容。 ## 曾考虑的替代方案 ### 为什么不保留? -当适配器、ACP 与压缩全部支持 image 时,`ContentBlockMap` 可以重新引入它。保留一个唯一实现是拒绝的核心类型,等于向外声明一个不可用的接口;移除则让生产者在编译期立即失败。 +当适配器、ACP 和压缩全部支持 image 时,`ContentBlockMap` 可以重新引入。保留一个唯一实现就是拒绝的核心类型,等于宣告一个不可用的对外服务接口;移除后,生产者会立即得到编译期错误。 -记录在案的回退方案(假设评审决定保留该槽位):保留 `ImageBlock`,但将每处静默跳过替换为显式拒绝,并在词汇文档中记录该策略——静默丢弃是唯一没有辩护者的状态。评审最终决定移除;此回退方案作为文档化的替代方案保留,以备该槽位在完整功能之前回归。 +评审中记录的回退方案(假如评审决定保留该槽位):保留 `ImageBlock`,但将所有静默跳过替换为显式拒绝,并在词汇文档中记录该策略——静默丢弃是唯一没有辩护者的状态。评审最终决定移除;此回退方案作为文档化的替代方案保留,以备该槽位在完整功能就绪之前回归。 ## 验证 -RFC 记录之外没有任何地方构造 harness `ImageBlock`。ACP 独立的入站 image 拒绝仍有测试覆盖,而适配器、编解码器与压缩的 default 分支则通过插件定义的块类型覆盖。 +RFC 记录之外没有任何地方构造 harness 的 `ImageBlock`。ACP 独立的入站 image 拒绝仍有测试覆盖,适配器、编解码器和压缩的默认分支则通过插件定义的块类型来覆盖。 ## 后果 -日后重新添加核心词汇类型会同时涉及多个包——但这种协调变更正是真正的多模态功能所需的形态(适配器映射、ACP 能力声明、压缩定价),而当前并没有什么需要保留的实现。 +日后重新添加核心词汇类型需要同时改动多个包(package)——但这种协调变更本就是真正的多模态功能所需的形态(适配器映射、ACP 能力宣告、压缩定价),而当前并不存在需要保留的实现。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml index 028162c527..3dabffc3a4 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-drop-inert-request-knobs.md: d5484aa5ec64f89ce705ddd0a434c2c4dfbad460 -2026-07-04-drop-inert-request-knobs.zh.md: 877b073c4693f0c87b5f25003a1d043743b658fa +2026-07-04-drop-inert-request-knobs.zh.md: 9bd13cd024bc0e2ba7795a190d77063c3457fe98 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md index 877b073c46..9bd13cd024 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md @@ -1,4 +1,4 @@ -# RFC:移除 `GenerateOptions.prefill` 与 `ToolSchema.strict`——无可用端到端路径的请求旋钮 +# RFC:移除 `GenerateOptions.prefill` 与 `ToolSchema.strict`——无端到端可用路径的请求旋钮 [English](2026-07-04-drop-inert-request-knobs.md) | 中文 @@ -6,30 +6,30 @@ Status: implemented ## 问题 -两个请求契约旋钮贯穿了整条请求流水线,但都无法产生任何效果: +两个请求契约旋钮贯穿了整条请求流水线,却都无法产生任何效果: -- **`prefill`**(`packages/llm/llm/src/types.ts`)没有生产环境的赋值方:agent loop(智能体循环)组装的只有 `model`/`system`/`tools`/`messages` 加 `sessionId`/`signal`,上下文压缩(context compaction)后端只追加 `maxTokens`;而且**两个**适配器都拒绝它:`packages/llm/llm-deepseek/src/serialize.ts` 和 `packages/llm/llm-pi-ai/src/adapter.ts` 各自在 `prefill` 非 undefined 时抛出 `LlmError('UNSUPPORTED')`。该字段全部可观测行为就是两个 throw,各由一个适配器测试固定。DeepSeek 的 chat-prefix completion 是一个 Beta 功能,使用的 base URL 两个适配器都未指向。 -- **`strict`**(`ToolSchema`,同一文件)贯穿了 `DefineToolOptions`/`defineTool`(`packages/core/tools/src/schema.ts`)、注册表的 `schemas()` 白名单(`packages/core/tools/src/index.ts`)、deepseek 协议格式(wire format)映射(`packages/llm/llm-deepseek/src/serialize.ts`,其 wire-type 注释记录了 strict 模式需要适配器未使用的 `/beta` base URL)、`packages/llm/llm-pi-ai/src/adapter.ts` 中的逐工具 payload 修补,以及 tool-catalog 渲染器(`scripts/gen-tool-catalog.ts`)中的条件 `Strict:` 行。没有任何已发布的工具设置过它:在所有 `tool-*` 包 src 和 `examples/` 中 `rg` 搜索,`strict:` 的生产方为零;唯一的赋值方是 dsh-tools 单元测试。 +- **`prefill`**(`packages/llm/llm/src/types.ts`)没有生产级的 setter:agent loop(智能体循环)组装的是 `model`/`system`/`tools`/`messages` 加 `sessionId`/`signal`,上下文压缩(context compaction)后端只追加 `maxTokens`;而且**两个**适配器都拒绝它:`packages/llm/llm-deepseek/src/serialize.ts` 和 `packages/llm/llm-pi-ai/src/adapter.ts` 各自在 `prefill` 非 undefined 时抛出 `LlmError('UNSUPPORTED')`。该字段的全部可观测行为就是两个 throw,各由一条适配器测试固定。DeepSeek 的 chat-prefix completion 是一个 Beta 功能,运行在两个适配器都未指向的 base URL 上。 +- **`strict`**(`ToolSchema`,同一文件)穿过了 `DefineToolOptions`/`defineTool`(`packages/core/tools/src/schema.ts`)、注册表的 `schemas()` 允许列表(`packages/core/tools/src/index.ts`)、deepseek 协议格式(wire format)映射(`packages/llm/llm-deepseek/src/serialize.ts`,其 wire-type 注释记录了 strict 模式需要适配器未使用的 `/beta` base URL)、`packages/llm/llm-pi-ai/src/adapter.ts` 中的逐工具 payload 修补逻辑,以及 tool-catalog 渲染器(`scripts/gen-tool-catalog.ts`)中的条件 `Strict:` 行。没有任何已发布的工具设置过它——在所有 `tool-*` 包的 src 和 `examples/` 中执行 `rg` 搜索,`strict:` 的生产者为零;唯一的 setter 出现在 dsh-tools 单元测试中。 -两个旋钮在适配器间是对称的,因此移除时两个孪生适配器一并清理——[孪生适配器设计](../architecture/2026-06-13-twin-llm-adapters.md)不受影响。 +两个旋钮在适配器间是对称的,因此移除操作将它们从两个孪生适配器中一并剥离——[孪生适配器设计](../architecture/2026-06-13-twin-llm-adapters.md)不受影响。 ## 决策 -- 从 `GenerateOptions` 中移除 `prefill`,同时移除两个适配器的 UNSUPPORTED 守卫、固定这些 throw 的测试、[core.md](../../../core-data-structures/core.md) 中的粘贴行,以及适配器 README 中记录拒绝行为的行。实操手册(Cookbook)中的 UNSUPPORTED 指导([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md))改为泛化表述——你的提供方无法兑现的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的后果部分将 prefill 记录为「受生产方门控」而非「已有归属」,遵照 [implemented/AGENTS.md](../AGENTS.md)。 -- 从 `ToolSchema`、`DefineToolOptions`、`defineTool`、`schemas()` 白名单、deepseek 序列化器分支及其 wire-type 字段、以及 tool-catalog 渲染器的 `Strict:` 行中移除 `strict`。pi-ai 的 payload 修补简化为无条件擦除 pi-ai 自身的逐工具 strict 默认值(pi-ai 在每个序列化工具上打 `strict: false`;手写的孪生适配器不发送此字段,因此擦除逻辑为保持协议格式对等而保留,由其序列化器测试固定)。赋值测试和 core.md 粘贴行已移除;`GenerateOptions` 与 `ToolSchema` 在 `scripts/type-equiv.manifest.json` 中保留各自的行,因为两个类型本身仍然存在,只是少了一个字段。 +- 从 `GenerateOptions` 中移除 `prefill`,同时移除两个适配器的 UNSUPPORTED 守卫、固定这些 throw 的测试、[core.md](../../../core-data-structures/core.md) 中的粘贴行,以及适配器 README 中记录拒绝行为的行。实操手册(cookbook)中的 UNSUPPORTED 指引([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md))改为泛化表述——你的 provider 无法兑现的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的后果部分将 prefill 记录为「受 producer 门控」而非「已有归属」,依据 [implemented/AGENTS.md](../AGENTS.md)。 +- 从 `ToolSchema`、`DefineToolOptions`、`defineTool`、`schemas()` 允许列表、deepseek 序列化分支及其 wire-type 字段,以及 tool-catalog 渲染器的 `Strict:` 行中移除 `strict`。pi-ai 的 payload 修补逻辑简化为对 pi-ai 自身逐工具 strict 默认值的无条件清除(pi-ai 在每个序列化的工具上打 `strict: false`;手写的孪生适配器不发送此字段,因此清除逻辑为保持协议格式对等而保留,由其序列化器测试固定)。setter 测试和 core.md 粘贴行已移除;`GenerateOptions` 与 `ToolSchema` 在 `scripts/type-equiv.manifest.json` 中保留各自的行,因为两个类型只是少了一个字段,本身仍然存在。 -本 RFC 有意**不**触及 `temperature`、`stop` 或 `maxTokens`:这些字段被两个适配器端到端地兑现,是 `agent/request` 上请求变更钩子插件的自然首选目标。 +本 RFC 有意**不**触碰 `temperature`、`stop` 或 `maxTokens`:它们在两个适配器中都被端到端地兑现,是 `agent/request` 上请求变更钩子插件的自然首选目标。 ## 曾考虑的替代方案 ### 为什么不保留? -「显式的 UNSUPPORTED throw 是诚实的契约行为」——但一个旋钮在两个孪生适配器中的唯一实现都是拒绝,它什么也不承诺;删除它反而升级了失败模式:意外的赋值从运行时 throw 变为编译错误。「strict schema 遵循是官方文档记录的提供方功能,且管道完整」——但一个旋钮在有已发布工具设置它**且**有端点兑现它之前,都不是产品表面;今天两者都不成立。二者各自随其第一个真实生产方回归:`prefill` 随实现了 chat-prefix completion 的适配器(以及对不支持它的适配器的明确策略)一起回来,`strict` 随需要它的工具和 beta 端点方案一起回来。 +「显式的 UNSUPPORTED throw 是诚实的契约行为」——但一个在两个孪生适配器中唯一的实现就是拒绝的旋钮,什么也没承诺;删除它反而升级了失败模式:意外的 setter 变成编译错误而非运行时 throw。「Strict schema 遵循是官方文档记载的 provider 功能,且管道完整」——但一个旋钮在有已发布的工具设置它**并且**有端点兑现它之前,不构成产品表面;今天两者都不成立。它们各自随首个真实 producer 回归:`prefill` 随实现了 chat-prefix completion 的适配器(以及对不支持该功能的适配器的明确策略)一起回来;`strict` 随需要它的工具和 beta 端点方案一起回来。 ## 验证 -`rg prefill` 仅返回 RFC 记录(本文与[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的 producer-gated 后果);在 tool-schema 范围内 `rg strict` 仅返回本 RFC、保留的 pi-ai 擦除逻辑,以及无关行文(如 `strictEqual`)。两个适配器的契约测试在移除守卫后通过,pi-ai 修补仍然擦除库的 strict 默认值——协议格式对等由其序列化器测试固定。 +`rg prefill` 仅返回 RFC 记录(本 RFC 与[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 中 producer-gated 的后果);在 tool-schema 范围内执行 `rg strict` 仅返回本 RFC、保留的 pi-ai 清除逻辑,以及 `strictEqual` 等无关文本。两个适配器的契约测试在移除守卫后通过,pi-ai 修补逻辑仍然清除库的 strict 默认值——协议格式对等由其序列化器测试固定。 ## 后果 -已发布的钩子桥接不设置任何请求字段,而请求变更插件(`agent/request` waterfall(瀑布式事件)监听器)使用的是 `temperature`/`stop`(保留且可用),而非适配器拒绝的字段。如果 chat-prefix completion 或 strict 模式成为产品功能,重新添加将随适配器/端点工作一起落地,届时契约能说明实际发生了什么,而非「所有人都 throw」。 +已发布的钩子桥接不设置任何请求字段,而请求变更插件(`agent/request` waterfall(瀑布式事件)监听器)使用的是 `temperature`/`stop`(保留且可用),而非适配器拒绝的字段。如果 chat-prefix completion 或 strict 模式成为产品功能,重新添加将随适配器/端点工作一起落地,届时契约能说明实际发生了什么,而不是「所有人都 throw」。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml index 429f89095a..fb1161ae18 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-drop-unconsumed-web-observation-surface.md: c48ff5b80c916cd6cc04d6a8339a8555d05b0d40 -2026-07-04-drop-unconsumed-web-observation-surface.zh.md: ce8a108450ed9e9308066ab45b8f000dc20fde39 +2026-07-04-drop-unconsumed-web-observation-surface.zh.md: 4988a5aa77604f528cc23409a3c2290c890a7f5a diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md index ce8a108450..4988a5aa77 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md @@ -1,4 +1,4 @@ -# RFC:移除未被消费的 web 观测面——`providers-change` 事件与 status 方法 +# RFC:移除未被消费的 web 观测接口——`providers-change` 事件与 status 方法 Status: implemented @@ -6,29 +6,29 @@ Status: implemented ## 问题 -`WebService` 暴露了一组没有任何生产代码观测的观测面: +`WebService` 暴露了一组没有任何生产代码观测的观测接口: -- **`web/providers-change`**(`packages/web/web/src/index.ts`)在每次 provider 注册和 dispose(资源释放)时声明并发射。每个注册 effect 的回滚 yield 被刻意排在 emit 之前,唯一目的是让一个抛异常的 change listener 能回退注册。该事件在包自身的两个单元测试之外没有任何 listener(其中一个测试的存在就是为了固定那个回滚顺序)。 -- **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一个包)没有任何生产调用方:`dsh-tool-web` 直接通过 `ctx.web.search()`/`fetch()` 执行,并将不可用状态以 seam 在执行时抛出的结构化 `WebError` 错误码呈现(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自身的测试。`packages/web/tool-web/README.md` 与 [architecture.md](../../../architecture.md) 中的行文声称工具「只读取聚合的 `searchStatus()`/`fetchStatus()`」——这种漂移之所以存活,仅仅因为没有什么机制会拿行文与调用点做比对。 +- **`web/providers-change`**(`packages/web/web/src/index.ts`)在每次 provider 注册和 dispose(资源释放)时声明并发出,且每个注册 effect 的回滚 yield 被刻意排在 emit 之前,唯一目的是让抛出异常的 change listener 能回退注册。在该包自身的两个单元测试之外没有任何 listener(其中一个测试的存在仅仅是为了固定那个回滚顺序)。 +- **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一个包)没有任何生产调用方:`dsh-tool-web` 通过 `ctx.web.search()`/`fetch()` 直接执行,并将不可用性表现为 seam 在执行时抛出的结构化 `WebError` 错误码(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自身的测试。`packages/web/tool-web/README.md` 和 [architecture.md](../../../architecture.md) 中的行文声称该工具「只读取聚合的 `searchStatus()`/`fetchStatus()`」——这是一处漂移,仅因没有机制检查行文与调用点的一致性而幸存。 -seam 自身的设计使两个观测面都失去了消费方:工具注册跟随产品 ENABLEMENT 而非 provider 可用性(`packages/web/tool-web/src/index.ts`),provider 选择在执行时解析、从不缓存——因此没有需要失效的缓存、没有需要重算的注册集合,也没有调用方需要一个独立于「执行并路由结构化错误」的可用性探针。HMR(热模块替换)清理由 effect disposer 自身承载。 +seam 自身的设计使这两个接口天然没有消费方:工具注册跟随产品 ENABLEMENT 而非 provider 可用性(`packages/web/tool-web/src/index.ts`),provider 选择在执行时解析且从不缓存——因此没有需要失效的缓存、没有需要重算的注册集合、也没有调用方需要一个有别于「执行并路由结构化错误」的可用性探测。HMR(热模块替换)清理由 effect disposer 自身承载。 -这与[移除未被消费的 `llm/adapter-change` 事件](../../implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md)如出一辙:那次从 `LlmService` 移除了相同的通知形态、相同的回滚先于 emit 机制,以及相同的 listener-throw 测试。该 RFC 的保留/裁剪判据——保留 `tools/change`(因为它有合理的面向用户的工具列表消费方),裁剪启动期后端注册表信号——把 web provider 注册表信号明确归入裁剪一侧;status 方法则是同一判断应用于拉取面而非推送面。 +这与 [移除未被消费的 `llm/adapter-change` 事件](../../implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) 如出一辙:那个 RFC 从 `LlmService` 中移除了相同的通知形态、相同的 rollback-before-emit 机制和相同的 listener-throw 测试。该 RFC 的保留/裁剪判据——为 `tools/change` 保留其合理的面向用户的工具列表消费方,裁剪启动时的后端注册表信号——将 web provider 注册表明确归入裁剪一侧;status 方法是同一判断应用于 pull 接口而非 push 接口。 ## 决策 -移除注册表变更事件、聚合 status 方法与类型,以及它们的专属测试。provider 私有的 status 保留用于执行时选择。面向调用方的覆盖率现在断言成功执行或结构化的选择错误,web 相关文档描述该按需调用契约。 +移除注册表变更事件、聚合 status 方法与类型,以及它们的专属测试。provider 私有的 status 保留用于执行时选择。面向调用方的覆盖率现在断言成功执行或结构化的选择错误,web 文档描述该按需调用契约。 ## 曾考虑的替代方案 ### 为什么不保留? -web seam RFC 当初有意指定了两者——事件作为最小的 HMR 可见性信号,status 方法作为工具的聚合诊断——且未来的 provider 状态面板是可以想象的。但同一 RFC 的其他选择使它们失去了消费方:按需派生的选择与基于 enablement 的注册使得没有消费方**能**需要它们;已交付的工具展示了真实模式(执行并路由结构化错误);漂移的 README 语句表明承诺的消费方从未实现。按照 AGENTS.md 的原则「RFC 是提案,不是金科玉律」,这些正是该提案中被代码证明过度延伸的部分;未来的观测者重新引入它实际消费的最小信号或查询,由该消费方塑造其形态。 +web seam RFC 有意指定了两者——事件作为最小的 HMR 可见性信号,status 方法作为工具的聚合诊断——且未来的 provider 状态面板是可以想象的。但同一 RFC 的其他设计选择使它们失去了消费方:按需派生的选择与基于 enablement 的注册使得没有消费方**能**需要这两者;已交付的工具展示了真实模式(执行并路由结构化错误);漂移的 README 语句表明承诺的消费方从未实现。按 AGENTS.md「RFC 是提案,不是金科玉律」的原则,这些是该提案中代码已证明过度设计的部分;未来的观测者按其实际消费的需求重新引入最小的信号或查询,由该消费方塑造其形态。 ## 验证 -`providers-change`、`searchStatus`、`fetchStatus` 和 `WebCapabilityStatus` 在 RFC 历史之外不再有任何拼写残留;catalog 是最新的(`verify-cordis-catalog` 绿色);注册/释放的 HMR 安全测试通过执行行为证明清理正确;tool-web README 与架构段落描述了工具实际拥有的执行时错误路由契约。 +在 RFC 历史之外不再有 `providers-change`、`searchStatus`、`fetchStatus` 或 `WebCapabilityStatus` 的拼写残留;catalog 是最新的(`verify-cordis-catalog` 绿色);注册/释放的 HMR 安全测试通过执行行为证明清理正确;tool-web README 与 architecture 段落描述了工具实际拥有的执行时错误路由契约。 ## 后果 -未来如果有 provider 选择器 UI 或诊断面板需要变更通知或 status 查询,它会重新添加自己实际消费的最小观测面;相同的判断及其反转条件已记录在 LLM 先例中。 +未来若有 provider 选择器 UI 或诊断面板需要变更通知或 status 查询,它将重新添加自身所消费的最小接口;相同的判断及其反转条件已记录在 LLM(大语言模型)先例中。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml index dfa01c3e89..4ca47feeee 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-fold-stdio-ui-helper.md: 8d26af190a957b424960519f77cbe132291c74de -2026-07-04-fold-stdio-ui-helper.zh.md: 879cce0d40e396b56f1a961b5ff190bab4fd70dd +2026-07-04-fold-stdio-ui-helper.zh.md: 795edf082258a56d3c11afbbb8de8cfa0e74e74e diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md index 879cce0d40..795edf0822 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md @@ -1,28 +1,28 @@ # RFC:将 stdio UI 辅助模块折入 stdio 应用 -Status: implemented - [English](2026-07-04-fold-stdio-ui-helper.md) | 中文 +Status: implemented + ## 问题 -readline UI 曾是一个完整的包(`@deepseek-ai/dsh-ui-stdio`,位于 `packages/support/`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载该应用来使用 readline UI,从不自行组合这个辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest 与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 分组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在——`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被文档标注为非产品表面的 support 包。 +readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/dsh-ui-stdio`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载应用来使用 readline UI,从不自行组合该辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest(元数据清单)与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在,`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被明确标注为非产品表面的 support 包。 -这条边界带来的是包元数据、workspace 与 tsconfig 引用、module-graph 行、README 条目,以及 publint 表面——服务于一个并不可独立替换的辅助模块:stdio 应用的前门集群总是包含 readline UI,且没有其他东西能有意义地消费它。 +这条边界换来的是:包元数据、workspace 与 tsconfig 引用、module-graph 行、README 条目,以及 publint 表面——服务于一个并不可独立替换的辅助模块:stdio 应用的前门集群始终包含 readline UI,且没有其他消费方能有意义地使用它。 ## 决策 -该辅助模块以终端通道插件的形式存在于 `@deepseek-ai/dsh-stdio` 中(`packages/ui/stdio/src/index.ts`):`createStdioChat`、其 `StdioRuntime` 测试 seam 及单元测试(`packages/ui/stdio/tests/stdio.spec.ts`、`readline.spec.ts`)一并迁入,因此 EOF 处理、渲染、dispose(资源释放)以及 piped-vs-TTY 行为在按文件覆盖率门禁下仍有单元测试覆盖,且无需劫持进程全局对象。该模块保持具名的 `name`/`inject`/`Config`/`apply` 导出形状——即应用通过 `ctx.plugin(uiStdio, …)` 挂载时消费的契约——而 `examples/echo-agent` 与 `examples/coding-agent` 中的 keyless Loader 路径冒烟测试继续证明组合树能通过真实 Loader 启动(stdio 包的插件形状单元测试套件固定了显式的 `unwrapExports` 断言,因为缺少 `inject` 的 bundle 会跳过一个意外的 default 导出而非崩溃)。 +该辅助模块作为终端通道插件存放在 `@deepseek-ai/dsh-stdio` 中(`packages/ui/stdio/src/index.ts`):`createStdioChat`、其 `StdioRuntime` 测试 seam 及单元测试(`packages/ui/stdio/tests/stdio.spec.ts`、`readline.spec.ts`)一并迁入,因此 EOF 处理、渲染、dispose(资源释放)以及管道/TTY 行为在按文件覆盖率门禁下仍有单元测试覆盖,且无需劫持进程全局对象。该模块保留具名的 `name`/`inject`/`Config`/`apply` 导出形状——即应用的 `ctx.plugin(uiStdio, …)` 挂载所消费的契约——而 `examples/echo-agent` 与 `examples/coding-agent` 中的 keyless Loader 路径冒烟测试继续证明组合树能通过真实 Loader 启动(stdio 包的插件形状单元测试套件固定了显式的 `unwrapExports` 断言,因为缺少 `inject` 的 bundle 会跳过一个意外的 default 导出而不是崩溃)。 -`packages/support/ui-stdio` 包已删除:manifest、tsconfig 引用、module-graph 行与 README 行均已清理;原先命名该包的文档注释(示例 e2e 模块文档、`packages/README.md`、support 与 todo README、[ui 分组 README](../../../../packages/ui/README.md))现在描述的是包内模块。 +`packages/support/ui-stdio` 包已移除:manifest、tsconfig 引用、module-graph 行与 README 行均已删除;曾命名该包的文档注释(示例 e2e 模块文档、`packages/README.md`、support 与 todo README、[ui 组 README](../../../../packages/ui/README.md))现在描述的是包内模块。 ## 曾考虑的替代方案 -### 为什么不将其提升到 `ui/`? +### 为什么不将其提升到 `ui/` 而是折入? -提升可以解决 support 与产品之间的错位,同时保留包边界——但只有在 readline UI 是一个可独立替换的集成或拥有第二个组合方时才是正确选择,而消费方普查表明两者都不成立。结构化的 ACP 桥接保持独立包,因为它是产品协议表面,拥有自己的契约和快照层级;readline 辅助模块只是一个应用前门的脚手架。在正式发布前重新拆出的成本很低:如果未来有第二个产品应用需要 readline UI,届时再拆出,由那个消费方来塑造包契约。 +提升可以解决 support 与 product 之间的错位,同时保留边界——只有在 readline UI 是一个可独立替换的集成或有第二个组合方时才是正确选择,而消费方普查表明两者皆非。结构化的 ACP 桥接保留为独立包,因为它是具有自身契约和快照层级的产品协议表面;readline 辅助模块只是一个应用前门的脚手架。在发布前重新拆分成本很低:如果将来有第二个产品应用需要 readline UI,届时再拆出来,由那个消费方来塑造包契约。 ## 后果 -- stdio 应用完整拥有自己的前门;一个叶子 `cordis.yml` 仍然只加载一个应用包,演示的形状没有变化。 +- stdio 应用完整拥有自己的前门;叶子 `cordis.yml` 仍然只加载一个应用包,演示的形态没有变化。 - 未来如果有独立的终端 UI 需要将该辅助模块作为包使用,届时由那个第二消费方驱动重新引入,而非仓库为假设性的复用保留一条边界。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml index f710b62445..ef7e3c8092 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-producerless-vocabulary-variants.md: 271b97f6217a2694f36b7fe7339eab6176dba9e5 -2026-07-04-prune-producerless-vocabulary-variants.zh.md: 5d75c0327e2faf5e6e37f8db959509161beda702 +2026-07-04-prune-producerless-vocabulary-variants.zh.md: 2fe8d41a011c37919bd01022d5be6d309b865bf7 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md index 5d75c0327e..2fe8d41a01 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md @@ -1,33 +1,33 @@ -# RFC:清理无生产者的词汇变体(块缓存提示、`agent` 消息来源、`continuation` 轮次触发器) - -Status: implemented +# RFC:裁剪无生产者的词汇变体(块缓存提示、`agent` 消息来源、`continuation` 轮次触发器) [English](2026-07-04-prune-producerless-vocabulary-variants.md) | 中文 +Status: implemented + ## 问题 -合并可扩展的词汇映射表设计上通过声明合并来增长,代码库已在 `TurnEndReasonMap`(`packages/core/session/src/types.ts`)上声明了准入策略:像 `refusal` 这样的变体「在适配器或循环首次发出它之前,有意不加入」。三个已声明的词汇项违反了这一策略——每个都既无生产者也无消费方,其中两个甚至没有测试: +可合并扩展的词汇映射表设计上通过声明合并来增长,代码库已在 `TurnEndReasonMap`(`packages/core/session/src/types.ts`)上明确了准入策略:像 `refusal` 这样的变体「在适配器或循环首次发出它之前,有意不纳入」。三个已声明的词汇项违反了该策略——每个都既无生产者也无消费方,其中两个甚至没有测试: -- **`CacheHint` 及其 `cache?: CacheHint` 块字段**,位于 `TextBlock`/`ToolResultBlock`(`packages/llm/llm/src/types.ts`;image block 上还有第三个同类字段,随 image block 一起移除——见[移除 image 的 RFC](2026-07-04-drop-image-content-block.md))。没有任何地方构造过带 `cache:` 的块——src、测试和文档粘贴全部搜索为空——两个适配器也都不读 `.cache`:DeepSeek 的 prompt 缓存是自动的,适配器只从响应中映射出 `prompt_cache_hit_tokens`,从不向请求中发送提示。这是 Anthropic 风格的 `cache_control` 接口面,却没有任何提供方能兑现它。 -- **`MessageSourceMap.agent`**(`{ kind: 'agent'; agentId: string }`,同一文件)。零个构造点,测试中也没有。它预期的生产者在上线时并未使用它:subagent 后端将父级的 prompt 发送给子级时不带 `source`,因此日志中记录为 `{ kind: 'user' }`,通用信封渲染器在插值 `source.kind` 时也从不按它路由。 -- **`TurnTriggerMap.continuation`**(`packages/core/session/src/types.ts`)。agent loop(智能体循环)在结构上不可能发出它——续写发生在一个轮次*内部*作为后续步骤,从不作为新轮次——循环只构造 `message` 和 `injection` 触发器。唯一的写入者是一个手工构建的测试 fixture(测试前置数据)(`packages/support/llm-replay/tests/llm-replay.spec.ts`),它只需要一个任意的非 message 触发器,`injection` 触发器同样满足需求;唯一的生产环境触发器读取者 ACP 桥接层只过滤 `kind === 'message'`。 +- **`CacheHint` 及其 `cache?: CacheHint` 块字段**,位于 `TextBlock`/`ToolResultBlock`(`packages/llm/llm/src/types.ts`;image block 上还有第三个同类字段,已随 image block 一起移除——见[移除 image block 的 RFC](2026-07-04-drop-image-content-block.md))。没有任何地方构造过带 `cache:` 的块——src、测试和文档粘贴全部搜索为空——两个适配器也都不读 `.cache`:DeepSeek 的 prompt 缓存是自动的,适配器只从响应中映射出 `prompt_cache_hit_tokens`,从不向请求中发送提示。这是 Anthropic 风格的 `cache_control` 接口面,却没有能兑现它的提供方。 +- **`MessageSourceMap.agent`**(`{ kind: 'agent'; agentId: string }`,同一文件)。零个构造点,包括测试在内。它预期的生产者在实现时并未使用它:subagent 后端将父级的 prompt 发送给子级时不带 `source`,因此记录为 `{ kind: 'user' }`,通用信封渲染器在插值 `source.kind` 时也从未对其做路由。 +- **`TurnTriggerMap.continuation`**(`packages/core/session/src/types.ts`)。agent loop(智能体循环)在结构上不可能发出它——continuation 发生在一个轮次*内部*作为后续步骤,而非作为新轮次——循环只构造 `message` 和 `injection` 触发器。唯一的写入者是一个手工构建的测试 fixture(测试前置数据),它只需要一个任意的非 message 触发器(`packages/support/llm-replay/tests/llm-replay.spec.ts`),`injection` 触发器同样满足需求;唯一的生产环境触发器读取方 ACP 桥接层只过滤 `kind === 'message'`。 ## 决策 -删除 `CacheHint`、其 `cache?` 块字段、`agent` 消息来源变体和 `continuation` 轮次触发器变体:已发布的词汇不再包含它们。llm-replay fixture 改用 `injection` 触发器(任何非 `message` 触发器都能满足其用途)。[core.md](../../../core-data-structures/core.md) 和 [session.md](../../../core-data-structures/session.md) 中的 type-equiv 粘贴与裁剪后的映射表一致——两个符号保留在 `scripts/type-equiv.manifest.json` 中,因为每个映射表只是少了一个成员——[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的「后果」部分将缓存提示记录为「受生产者门控」而非「已有归属」,遵照 [implemented/AGENTS.md](../AGENTS.md)。 +删除 `CacheHint`、其 `cache?` 块字段、`agent` 消息来源变体与 `continuation` 轮次触发器变体:发布的词汇表不再包含它们。llm-replay fixture 改用 `injection` 触发器(任何非 `message` 触发器均满足其用途)。[core.md](../../../core-data-structures/core.md) 和 [session.md](../../../core-data-structures/session.md) 中的 type-equiv 粘贴与裁剪后的映射表一致——两个符号保留在 `scripts/type-equiv.manifest.json` 中,因为每个映射表本身仍然存在,只是少了一个成员——[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的后果部分将缓存提示记录为「受生产者门控」而非「已有归属」,依照 [implemented/AGENTS.md](../AGENTS.md)。 -每个变体在获得真正的生产者之日回归,这正是映射表设计上的增长方式:缓存功能连同传输它的适配器一起重新添加 `cache`;subagent 归属连同打标的后端和路由它的消费方一起重新添加 `agent`;真正启动新轮次的自动续写功能连同发出它的插件一起重新添加 `continuation`。 +每个变体在获得真正的生产者之日回归,这正是映射表设计的增长方式:缓存功能连同传输它的适配器一起重新添加 `cache`;subagent 归属连同打标的后端和路由它的消费方一起重新添加 `agent`;真正启动新轮次的自动续行功能连同发出它的插件一起重新添加 `continuation`。 ## 曾考虑的替代方案 ### 为什么不保留它们? -[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 将「缓存提示……已有归属」列为设计后果,预留的槽位确实能传达意图。但一个空槽位是契约面,每个实现和消费方都必须考虑它(我的适配器是否必须兑现 `cache`?我的渲染器是否必须路由 `agent` 来源?),而兄弟映射表自身的 JSDoc 已经拒绝了「无发出者的预留」——`refusal` 和 `max_turn_requests` 被标注为*当有东西首次发出它们时*再添加的变体,而非提前声明。对已声明但无生产者的变体执行同一标准,才能让词汇表有意义:如果它在映射表里,就一定有东西在生产它。 +[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 将「缓存提示……已有归属」列为设计后果,预留槽位确实能表达意图。但一个空槽位是每个实现和消费方都必须考虑的契约面(我的适配器需要兑现 `cache` 吗?我的渲染器需要路由 `agent` 来源吗?),而同族映射表自身的 JSDoc 已经拒绝了「无发出者的预留」——`refusal` 和 `max_turn_requests` 被明确标注为*当某物首次发出它们时*再添加的变体,而非提前声明。对已声明但无生命的变体施加同样的标准,使词汇表具有实际意义:如果它在映射表中,就一定有东西在生产它。 ## 验证 -`rg` 搜索 `CacheHint`、`agent` 消息来源的拼写和 `continuation` 触发器的拼写,只返回 RFC 记录(本文,以及[移除 image 的 RFC](2026-07-04-drop-image-content-block.md) 中关于 image block 自身 `cache` 字段的描述);llm-replay fixture 使用 `injection` 触发器断言相同的回放行为;核心数据结构粘贴与 type-equiv manifest(元数据清单)保持同步。 +对 `CacheHint`、`agent` 消息来源拼写和 `continuation` 触发器拼写执行 `rg` 搜索,结果仅返回 RFC 记录(本文,以及[移除 image block 的 RFC](2026-07-04-drop-image-content-block.md) 中关于 image block 自身 `cache` 字段的说明);llm-replay fixture 使用 `injection` 触发器断言了相同的回放行为;core-data-structures 粘贴与 type-equiv manifest 保持同步。 ## 后果 -没有运行时行为改变——本来就没有任何东西能构造这些值。镜像事件的移除([边界镜像 RFC](2026-06-20-remove-agent-boundary-mirror-events.md)、[流式分片镜像 RFC](2026-07-02-remove-stream-chunk-mirror.md))只涉及瞬态的 `agent/*` 事件,从不触及持久化词汇,因此不存在冲突。其他地方准入策略已经生效:`rejected`、`prompt/blocked` 和 `hook/invoked`/`hook/result` 各自都有活跃的生产者——本 RFC 将同一标准延伸到缺少生产者的三个变体。image block 自身的 `cache?` 字段属于[移除 image 的 RFC](2026-07-04-drop-image-content-block.md),随该块一起移除;本 RFC 覆盖的是保留下来的块类型上的两个字段。 +没有任何运行时行为改变——本来就没有东西能构造这些值。镜像事件的移除([boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md)、[stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md))只涉及瞬态的 `agent/*` 事件,从不涉及持久词汇,因此不存在冲突。其他地方准入策略已经成立:`rejected`、`prompt/blocked` 和 `hook/invoked`/`hook/result` 各自都有活跃的生产者——本 RFC 将同一标准延伸到缺少生产者的三个变体。image block 自身的 `cache?` 字段属于[移除 image block 的 RFC](2026-07-04-drop-image-content-block.md),已随该块一起移除;本 RFC 覆盖的是留存块类型上的两个字段。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml index 3a4db54e54..428dfb9379 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-write-only-fs-surface.md: ac2cbcc282b848b26a3d5d327e0ab54612b5ac91 -2026-07-04-prune-write-only-fs-surface.zh.md: 799847aa77599a292c1aa48e150aae99fa130ae3 +2026-07-04-prune-write-only-fs-surface.zh.md: e854df76cae74033404aa9cc1986fdd118f19b10 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md index 799847aa77..e854df76ca 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md @@ -1,32 +1,32 @@ -# RFC:从 fs seam 中移除只写字段与一个无效路由旋钮 - -Status: implemented +# RFC:从 fs seam 中移除只写字段与一个无效的路由旋钮 [English](2026-07-04-prune-write-only-fs-surface.md) | 中文 +Status: implemented + ## 问题 -[fs seam 拆分](2026-06-26-fsspec-style-fs-seam.md)将读取路由与策略从后端移入 `dsh-tool-fs` 和 `dsh-fs-policy`。四处接口保留了拆分前的形态——每次调用都填充,却无人读取: +[fs seam 拆分](2026-06-26-fsspec-style-fs-seam.md)将读取路由与策略从后端移至 `dsh-tool-fs` 和 `dsh-fs-policy`。有四处接口保留了拆分前的形态——每次调用都填充,却无人读取: -1. **`dsh-fs-local` 中的 `STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize`**——*在本次变更之前已被"无硬编码可调参数"审计移除,该审计将路由阈值改为 `dsh-tool-fs` 的 `readStreamMinSize` 配置;此处记录是为了完整呈现整个裁剪。*原始位置(`packages/fs/fs-local/src/fsio.ts`,从 `packages/fs/fs-local/src/index.ts` 再导出):包括 fs-local 自身源码和测试在内,全仓库零读取者。后端不做读取路由——`readWholeText`/`streamWholeText` 是调用方自行选择的独立原语——真正的路由常量在消费方(`packages/fs/tool-fs/src/read.ts`,与 `info.size` 比较)。10 MiB 这个事实有两份镜像;后端那份是死代码,而该旋钮的 JSDoc 声称提供一个并不存在的"读取路由"覆盖。 -2. **`FsTarget.inputPath`**(`packages/fs/fs/src/types.ts`):每个后端和每个测试 fake 都必须编造一个"仅用于诊断"的值,而生产环境零读取者——策略插件和所有错误消息使用的是 `targetKey`/`displayPath`。`listDir` 的生产者暴露了语义摇摆:目录子项拿到的是裸条目名,这不是任何人的"输入路径"。 -3. **`FsEditOutcome.replacements` + `.replaceAll`**(`packages/fs/fs/src/types.ts`):`replacements` 生产环境零读取者(单匹配策略本身保留——它由后端内部的 `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` 抛出强制执行,错误消息保留了内部计数);`replaceAll` 仅被 `packages/fs/tool-fs/src/edit.ts` 中的 `formatEditOutput` 读取——作为工具已持有的 `replace_all` 参数的回声。精简后,`FsEditOutcome` 变为 `{ version, before, after }`,与 `FsWriteOutcome` 中真正由后端发现的字段对齐。 -4. **`FileReadOutcome.limit` + `.version`**(`packages/fs/tool-fs/src/read-render.ts`):由读取工具填充,但 `formatReadOutput` 只渲染 `offset`/`lines`/`totalLines`/`truncatedByBytes`,而 `fs/observed` 事件直接使用 `info.version`,不使用 outcome 的副本。 +1. **`dsh-fs-local` 中的 `STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize`**——*在本次变更之前已被「禁止硬编码可调参数」审计移除,该审计将路由阈值改为 `dsh-tool-fs` 的 `readStreamMinSize` 配置;此处记录是为了完整呈现整次清理。* 原始位置(`packages/fs/fs-local/src/fsio.ts`,从 `packages/fs/fs-local/src/index.ts` 重导出):包括 fs-local 自身源码和测试在内,全仓库零读取者。后端没有读取路由——`readWholeText`/`streamWholeText` 是调用方自行选择的两个独立原语——真正的路由常量位于消费方(`packages/fs/tool-fs/src/read.ts`,与 `info.size` 比较)。同一个 10 MiB 事实的两份镜像;后端那份是死代码,且该旋钮的 JSDoc 声称提供一个实际不存在的「read routing」覆盖。 +2. **`FsTarget.inputPath`**(`packages/fs/fs/src/types.ts`):每个后端和每个测试 mock 都必须为这个「仅供诊断」的字段编造一个值,而生产环境零读取者——策略插件和所有错误消息使用的是 `targetKey`/`displayPath`。`listDir` 的生产者暴露了语义上的摇摆:目录子项得到的是裸条目名,这不是任何人的「input」。 +3. **`FsEditOutcome.replacements` + `.replaceAll`**(`packages/fs/fs/src/types.ts`):`replacements` 生产环境零读取者(单匹配策略本身保留——它由后端内部 `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` 抛出来强制执行,错误消息保留了内部计数);`replaceAll` 仅被 `packages/fs/tool-fs/src/edit.ts` 中的 `formatEditOutput` 读取——作为工具本身已持有的 `replace_all` 参数的回声。精简后,`FsEditOutcome` 变为 `{ version, before, after }`,与 `FsWriteOutcome` 中真正由后端发现的字段对齐。 +4. **`FileReadOutcome.limit` + `.version`**(`packages/fs/tool-fs/src/read-render.ts`):由读取工具填充,但 `formatReadOutput` 只渲染 `offset`/`lines`/`totalLines`/`truncatedByBytes`,且 `fs/observed` 事件发射直接使用 `info.version` 而非 outcome 的副本。 ## 决策 -删除 fs-local 常量及其再导出和 `streamMinSize` 旋钮(`FsIoInternals` 中剩余的旋钮确实被原子写入测试使用);从 `FsTarget` 中移除 `inputPath`;将 `FsEditOutcome` 精简为 `{ version, before, after }`,并将 `replaceAll` 从解析后的参数传给 `formatEditOutput`;从 `FileReadOutcome` 中移除 `limit`/`version`。[filesystem.md](../../../core-data-structures/filesystem.md) 中的粘贴内容、`packages/fs/fs/README.md`,以及那些不得不编造被移除字段的测试 fake 随类型一起精简。 +删除 fs-local 的常量及其重导出,以及 `streamMinSize` 旋钮(`FsIoInternals` 中剩余的旋钮确实被原子写入测试使用);从 `FsTarget` 中移除 `inputPath`;将 `FsEditOutcome` 精简为 `{ version, before, after }`,并将 `replaceAll` 从解析后的参数传入 `formatEditOutput`;从 `FileReadOutcome` 中移除 `limit`/`version`。[filesystem.md](../../../core-data-structures/filesystem.md) 中的粘贴内容、`packages/fs/fs/README.md`,以及那些不得不为已移除字段编造值的测试 mock,都随类型一起缩减。 ## 曾考虑的替代方案 ### 为什么不保留? -未来的权限/隔离层可能需要解析前的路径来生成错误文本——但它需要的是*请求*,每个调用点仍然持有请求。"替换了 N 处"可能成为面向模型的文本——那是需要时再设计的行为变更,且后端内部的计数为其错误消息保留着。读取页脚可能展示 `limit`——页脚展示的一切已经可以从 `lines`/`totalLines` 推导。与此同时,当前和未来的每个后端(远程、原生)都必须编造无人消费的协议格式(wire format)字段,每个测试 fake 都必须满足它们。 +未来的权限/隔离层可能需要解析前的路径来生成错误文本——但它需要的是*请求*,每个调用点仍然持有请求。「替换了 N 处」可能成为面向模型的文本——这是一个需要时再设计的行为变更,且后端内部的计数为其错误消息而保留。读取页脚可能展示 `limit`——但页脚展示的一切已经可以从 `lines`/`totalLines` 推导。与此同时,每个现有和未来的后端(远程、原生)都必须编造无人消费的协议字段,每个测试 mock 都必须满足它们。 ## 验证 -被移除的接口已不存在——`dsh-fs-local` 中的 `STREAM_MIN_SIZE`/`streamMinSize`、`FsTarget.inputPath`、`FsEditOutcome.replacements`/`.replaceAll`、`FileReadOutcome.limit`/`.version`——而请求侧的 `replaceAll`(`FsEditRequest`)和其他 outcome 类型上的 version 字段未受影响;测试 fake 随类型一起精简。`formatEditOutput` 在 `replace_all` 两个分支下的输出文本不变,因此没有快照 golden 被搅动。 +被移除的接口已消失——`dsh-fs-local` 中的 `STREAM_MIN_SIZE`/`streamMinSize`、`FsTarget.inputPath`、`FsEditOutcome.replacements`/`.replaceAll`,以及 `FileReadOutcome.limit`/`.version`——而请求侧的 `replaceAll`(`FsEditRequest`)和其他 outcome 类型上的 version 字段未受影响;测试 mock 随类型一起缩减。`formatEditOutput` 在 `replace_all` 两个分支下输出的文本不变,因此没有快照黄金文件被搅动。 ## 后果 -后端不增加新义务;它们卸下了四个无人消费的字段。fs 发现工作(glob/grep 工具)触及相同的 `dsh-fs` 类型文件——这是文本层面而非设计层面的重叠,可以机械地解决。 +后端不增加新义务,反而卸下了四个无人消费的字段。fs 发现功能(glob/grep 工具)涉及相同的 `dsh-fs` 类型文件——这是文本层面而非设计层面的重叠,可以机械地合并解决。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml index 1e1c22f3c1..be2b32be56 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-remove-agent-steering-mirror.md: 311f0f8ffd278adf71a35617b900d4d16037055a -2026-07-04-remove-agent-steering-mirror.zh.md: 4ceb31a0263a7c386ceed071d3f0db315c7a9f23 +2026-07-04-remove-agent-steering-mirror.zh.md: 24198afb0714863867f4d7e17ae19ea8af6a88bd diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md index 4ceb31a026..24198afb07 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md @@ -1,4 +1,4 @@ -# RFC:移除 `agent/steering` 镜像事件发射 +# RFC:移除 `agent/steering` 镜像 emit [English](2026-07-04-remove-agent-steering-mirror.md) | 中文 @@ -6,28 +6,28 @@ Status: implemented ## 问题 -`agent/steering` 是最后一个仍存在的、对持久会话事件的瞬态镜像。循环的 steering 排空逻辑先追加持久事件 `steering/message { turn, content, source }`,紧接着下一行就发射 `agent/steering(agent, turn, content, source)`——同一个事实以 fire-and-forget 事件的形式重复一遍(`packages/core/agent-loop/src/loop.ts`,`drainSteering`)。它在生产环境中没有任何监听者:唯一的订阅方是一个循环回归测试,断言该发射携带了 `source`——而这个事实在上一行的持久事件中已经记录。 +`agent/steering` 是最后一个仍存在的、对持久会话事件的瞬态镜像。agent loop(智能体循环)的 steering(中途引导)drain 逻辑先追加持久事件 `steering/message { turn, content, source }`,紧接着下一行就 emit `agent/steering(agent, turn, content, source)`——同一个事实以 fire-and-forget 事件的形式重复发出(`packages/core/agent-loop/src/loop.ts`,`drainSteering`)。它在生产环境中没有任何监听者:唯一的订阅方是一个 agent loop 回归测试,断言 emit 携带了 `source`——而这同一个事实已经由上一行的持久事件记录。 -`agent/steering` 以相同的 payload 复制了紧邻其前的持久事件 `steering/message`。`agent/queued` 则保留为纯 live 信号,因为它在持久化之前触发,覆盖了可能在进入日志前被取消的工作。 +`agent/steering` 以相同的 payload 重复了紧接其前的持久事件 `steering/message`。`agent/queued` 仍保留为纯瞬态信号,因为它在持久化之前触发,覆盖了可能在进入日志前被取消的工作。 -steering(中途引导)承载着真实的生产流量:钩子桥的轮次续行决策通过 `inbox.steer()` 注入原因,落地为持久的 `steering/message` 事件,钩子矩阵的 golden 文件固定了这些事件。所有这些消费方观察的都是持久事件,没有任何消费方观察镜像。 +steering 承载着真实的生产流量:hook bridge 的轮次续行决策通过 `inbox.steer()` 注入理由,落地为持久的 `steering/message` 事件,hook-matrix 的 golden 文件对此进行固定——所有这些消费方观察的都是持久事件。没有任何消费方观察镜像事件。 ## 决策 -`agent/steering` 从 agent 事件分类体系中移除:`packages/core/agent/src/types.ts` 中的声明(及其在 live-events JSDoc 列表中的提及)、`drainSteering` 中的发射(随之移除的还有当时已无用的 `ctx` 参数)、`packages/core/agent/README.md` 中的对应行,以及循环伪代码块中的发射行(`packages/core/agent-loop/src/loop.ts` 模块文档与 [architecture.md](../../../architecture.md));Cordis catalog 重新生成后不再包含它。唯一的回归测试改为在持久事件 `steering/message` 上固定 source 保持——它所固定的事实存在于日志中。 +`agent/steering` 从 agent 事件分类体系中移除:`packages/core/agent/src/types.ts` 中的声明(及其在 live-events JSDoc 列表中的提及)、`drainSteering` 中的 emit(随之移除的还有当时已无用的 `ctx` 参数)、`packages/core/agent/README.md` 中的对应行,以及 loop 伪代码块中的 emit 行(`packages/core/agent-loop/src/loop.ts` 模块文档与 [architecture.md](../../../architecture.md));Cordis catalog 重新生成后不再包含它。唯一的回归测试改为在持久事件 `steering/message` 上固定 source 保持性——它所固定的事实存在于日志中。 -三份已实施的 RFC 曾声明保留该事件,每份均按 [implemented/AGENTS.md](../AGENTS.md) 修订,指向本 RFC 作为移除记录:[boundary RFC](2026-06-20-remove-agent-boundary-mirror-events.md) 的保留列表条目、[stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md) 的范围条款,以及 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 的瞬态发射枚举。 +三份已实施的 RFC 曾声明保留该事件,每份均按 [implemented/AGENTS.md](../AGENTS.md) 的要求修订,指向本 RFC 作为移除记录:[boundary RFC](2026-06-20-remove-agent-boundary-mirror-events.md) 的保留列表条目、[stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md) 的范围条款,以及 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 的瞬态 emit 枚举。 ## 曾考虑的替代方案 ### 为什么不保留? -"它是控制信号,不是边界事件"——但分类体系的实际区分维度是「镜像 vs 纯 live」,而非「控制 vs 边界」,而这个事件属于镜像。需要入队时通知的消费方有 `agent/queued`(带 steering flag);需要排空时通知的消费方本质上是在请求 `steering/message` 被追加的那一刻,而 `session/event` 以相同 payload 加上持久性提供了这一点。被否决的 [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) 捍卫的是 steering **能力**——`steer()`、持久事件、续行强制——本次移除对这些全部不动。 +"它是控制信号,不是边界事件"——但分类体系的操作性区分是「镜像 vs. 纯瞬态」,而非「控制 vs. 边界」,而这个事件属于镜像。需要入队时通知的消费方有 `agent/queued`(带 steering flag);需要 drain 时通知的消费方,本质上是在请求 `steering/message` 被追加的那一刻,而 `session/event` 以相同 payload 加上持久性提供了这一通知。被否决的 [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) 捍卫的是 steering **能力**——`steer()`、持久事件、续行强制——本次移除对这些全部保持不变。 ## 验证 -`agent/steering` 这一拼写仅存在于 RFC 行文中(本 RFC、上述三份修订后的 RFC,以及冻结的[被否决 steering 能力 RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md),其文本记录了它所拒绝的提案);catalog 已重新生成;重定向后的测试在 `steering/message` 上固定 source 保持。 +`agent/steering` 这一拼写仅存于 RFC 行文中(本 RFC、上述三份修订的 RFC,以及冻结的[被否决的 steering 能力 RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md),其文本记录了它所拒绝的提案);catalog 已重新生成;重定向后的测试在 `steering/message` 上固定 source 保持性。 ## 后果 -没有需要迁移的生产监听者。两种 live 通知需求都保留了归属:入队时通知归 `agent/queued`(带 `steering` flag),排空时通知归 `session/event`(持久的 `steering/message` 落地时触发)。 +生产环境中没有需要迁移的监听者,两种瞬态通知需求各有归宿:入队时由 `agent/queued`(带 `steering` flag)承载,drain 时由 `session/event` 在持久事件 `steering/message` 落地时承载。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml index 16316292cd..6a470484f3 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-share-app-bin-boot-glue.md: afaa61fe909f3dbf900337518969410780020a88 -2026-07-04-share-app-bin-boot-glue.zh.md: 2a022fbb8dcdf6977ede81780a87c570715ce441 +2026-07-04-share-app-bin-boot-glue.zh.md: 33fe2f27df9c8b172e4296ece0720813f9775f86 diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md index 2a022fbb8d..33fe2f27df 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md @@ -1,27 +1,27 @@ -# RFC:共享应用 bin 的启动胶水代码,不再维护两份副本 - -Status: implemented +# RFC:共享应用 bin 的启动胶水代码,而非维护两份副本 [English](2026-07-04-share-app-bin-boot-glue.md) | 中文 +Status: implemented + ## 问题 -stdio 和 ACP bin 各自重复了环境加载、fail-loud 处理、入口校验与启动逻辑,包括微妙的 Loader 失败行为。两份副本已经发生漂移,且位于自执行文件中、被排除在单元测试覆盖率之外,导致其中的辅助导出无法被复用。 +stdio 和 ACP 两个 bin 各自重复了环境加载、fail-loud 处理、入口校验与启动逻辑,包括微妙的 Loader 失败行为。两份副本已经发生漂移,且位于自执行文件中、被排除在单元测试覆盖率之外,导致其导出的辅助函数无法被复用。 ## 决策 -辅助逻辑只存在一处:[`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot)(`packages/ui/app-boot`,归入 `ui` 组,因为 bin 是已发布产物,其运行时依赖本身也必须是已发布的,而非 `support/`)。包含:`resolveConfigPath`(快照感知,两个 bin 共用的唯一路径解析器)、`loadEnv`、`installFailLoud`、`assertEntriesLoaded` 和 `boot`,每个函数都按 bin 的诊断前缀参数化,并在其副作用 seam(warn sink、process 切片)处可注入,使单元测试套件能覆盖每个分支——包括 `boot()` 在进程内驱动真实 Loader、使用相对路径 specifier 的配置,涵盖已就绪树的正常路径和无 fiber 入口的拒绝路径。该包(package)启用了逐文件 100% 覆盖率门禁;Loader 失败的经验知识只有一个归属地。 +辅助函数只存在一处:[`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot)(`packages/ui/app-boot`,归入 `ui` 分组,因为 bin 是已发布产物,其运行时依赖本身也必须是已发布的包,而非 `support/`)。包含:`resolveConfigPath`(快照感知,两个 bin 共用的唯一路径解析器)、`loadEnv`、`installFailLoud`、`assertEntriesLoaded` 与 `boot`,每个函数都通过 bin 的诊断前缀参数化,并在其副作用 seam(warn sink、process slice)处支持注入,使单元测试套件能覆盖每个分支——包括 `boot()` 在进程内驱动真实 Loader、使用相对路径 specifier 配置的场景,既覆盖已稳定树的正常路径,也覆盖无 fiber 入口的拒绝路径。该包启用逐文件 100% 覆盖率门禁;Loader 失败的相关知识只有一个归属地。 -每个 `bin.ts` 是一个精简的自执行组合:在共享辅助逻辑之上叠加各自应用特有的生命周期(ACP bin:replay 模式下跳过环境加载与 stdin-EOF dispose;stdio bin:无额外逻辑)。bin 文件仍然被排除在覆盖率之外且不导出任何内容;已发布产物的防护措施不变——built-bin 冒烟测试仍然在一个 node_modules 形状的临时目录下用原生 node 运行每个 bin(现在也 symlink 了 `ui/app-boot`),并仍然断言缺少配置时的非零退出码,遵循「真实入口路径意味着已发布产物」的防御模式。[extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md) 中关于 bin 归属的事实已相应修订。 +每个 `bin.ts` 是一个精简的自执行组合,基于共享辅助函数加上各自特有的应用生命周期(ACP bin:replay 模式下跳过 env 加载与 stdin-EOF dispose;stdio bin:无额外逻辑)。bin 文件仍被排除在覆盖率之外且不导出任何内容;已发布产物的守卫不变——built-bin 冒烟测试仍在 node_modules 形状的临时目录中以原生 node 运行每个 bin(现在也符号链接了 `ui/app-boot`),并仍断言缺少配置时的非零退出码,遵循「真实入口路径即已发布产物」的防御模式。[extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md) 中关于 bin 归属的事实已相应修订。 ## 曾考虑的替代方案 -### 为什么不保留重复? +### 为何不保留重复? -bin 被定位为独立拥有的已发布产物,而新增一个包有固定开销(manifest、README、tsconfig reference、publint 表面积),与去重的代码行数相当。但创建 bin 的那份 RFC 从未权衡过应用间共享——它把三个示例 `start.ts` 副本合并**进**了 bin 就止步了;漂移是已观察到的事实;而覆盖率缺口的论点独立于去重论点:这是仓库中唯一被豁免于逐文件 100% 门禁的非平凡运行时逻辑。记录在案的回退方案(仅将纯逻辑提取为各应用模块)可以结束豁免,但会保留两个经验知识归属地。 +bin 被定位为独立拥有的已发布产物,而新增一个包(package)带来的固定开销(manifest(元数据清单)、README、tsconfig reference、publint 表面积)与去重的代码行数相当。但创建 bin 的那份 RFC 从未权衡过应用间共享的可能——它将三份示例 `start.ts` 副本合并进 bin 后便止步了;漂移是已观察到的事实;而覆盖率缺口的论据独立于去重论据:这是仓库中唯一免于逐文件 100% 门禁的非平凡运行时逻辑。记录在案的备选方案(仅将纯逻辑提取为各应用自己的模块)虽能终结豁免,但会保留两个知识归属地。 ## 后果 -- 启动胶水代码的变更(新增守卫、修复解析)只需落地一次,两个已发布 bin 自动继承;bin 之间不会再次漂移。 -- `dsh-app-boot` 保持依赖精简(cordis + loader/include 对)——它是启动机制,不是应用接口。 -- bin 自身的文件是近乎平凡的组合;所有带分支的逻辑都在覆盖率门禁之下。 +- 启动胶水代码的变更(新增守卫、修复路径解析)只需落地一次,两个已发布 bin 自动继承;bin 之间不会再次漂移。 +- `dsh-app-boot` 保持轻量依赖(cordis + loader/include 对)——它是启动机制,不是应用表面积。 +- bin 自身的文件几乎是平凡的组合;所有含分支的逻辑都在覆盖率门禁之下。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml index 38cd3003a8..95c2fdbf50 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-tighten-hook-protocol-contract.md: df438516b836315902378afe7f4fd09e512c0966 -2026-07-04-tighten-hook-protocol-contract.zh.md: decc6ba86131f6d1930eb51a1267a9668d91dcb2 +2026-07-04-tighten-hook-protocol-contract.zh.md: 256da42993c8581e8bce861ecbfac22dbf5f0545 diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md index decc6ba861..256da42993 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md @@ -1,4 +1,4 @@ -# RFC:收紧 hook 协议契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 +# RFC:收紧 hook-protocol 契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 [English](2026-07-04-tighten-hook-protocol-contract.md) | 中文 @@ -8,25 +8,25 @@ Status: implemented `dsh-hook-protocol`/bridge 契约中有四处遗漏了 [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) 所记录的纪律——该 RFC 因缺乏消费方而移除了 `agentType` 生命周期字段,以下四处未通过同样的检验: -1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有任何生产者——bridge 只打 `'claude'` 和 `'codex'` 标记;唯一的 `'native'` 构造出现在 lib 自身的单元测试中。该字段自己的 JSDoc 将 `dialect` 定义为「执行它的 bridge」,而 native 不是 bridge:[interception-seams RFC](../feature/2026-06-30-interception-seams.md) 记录了 native 钩子不是一个 package,且「native 插件已经可以直接使用类型化的 Decisions」而无需持久化的 hook 日志;旗舰 native 插件的工作示例也正是如此断言的(完全没有 `hook/*` 事件)。 -2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上都被丢弃:没有 bridge 分支、没有 merge fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中,它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本没有什么可 suppress 的:hook 的 stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此 hook 作者设置 `suppressOutput: true` 得到的是无声的空操作,连 warn 都没有。 -3. **`defaultTimeoutMs` 在两个 bridge 配置中被双重默认,使用浮动字面量**——一个 schema `.default(600_000)` 加一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),每个 bridge 为同一个协议级常量提供两个归属,两个 bridge 可能在共享默认值上悄然分歧。*本提案最初的补救——彻底删除该配置项——被 no-hardcoded-tunables 审计取代,后者保留了该配置项作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下需要修复的是字面量的归属。* -4. **`hook/result` 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。** `summarize()`——stderr 截断规则——在 `packages/hooks/hooks-claude/src/index.ts` 和 `packages/hooks/hooks-codex/src/index.ts` 中逐字节相同,decision 字符串规则 `output.decision ?? (output.continue === false ? 'stop' : 'pass')` 也是如此;然而 `dsh-hook-protocol` 声明了 `hook/result`、将 `stderrSummary` 文档化为「已截断」却不拥有截断逻辑,将 decision 值文档化却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享的持久化事件的语义就会悄然分叉。 +1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有任何生产者——bridge 只会标记 `'claude'` 和 `'codex'`;唯一构造 `'native'` 的地方是 lib 自身的单元测试。该字段的 JSDoc 将 `dialect` 定义为「运行它的 bridge」,而 native 并非 bridge:[interception-seams RFC](../feature/2026-06-30-interception-seams.md) 记录了 native hook 不是一个 package,且「native 插件已经可以直接使用类型化的 Decisions」而无需持久化 hook 日志;旗舰 native-plugin 示例也正是如此断言的(完全没有 `hook/*` 事件)。 +2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有 merge fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本无物可抑制:hook stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此 hook 作者设置 `suppressOutput: true` 得到的是无声的空操作,且无任何警告。 +3. **`defaultTimeoutMs` 在两个 bridge 配置中以浮动字面量双重默认**——schema 的 `.default(600_000)` 加上一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。*提案最初的补救措施是彻底删除该旋钮,但被 no-hardcoded-tunables 审计所取代:审计保留了该旋钮作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下要修的是字面量的归属地。* +4. **`hook/result` 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。** `summarize()`——stderr 截断规则——在 `packages/hooks/hooks-claude/src/index.ts` 与 `packages/hooks/hooks-codex/src/index.ts` 中逐字节相同;decision 字符串规则 `output.decision ?? (output.continue === false ? 'stop' : 'pass')` 同样如此。然而 `dsh-hook-protocol` 声明了 `hook/result`、在文档中将 `stderrSummary` 描述为「已截断」却不拥有截断逻辑,记录了 decision 值却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享持久化事件的语义就会悄然分叉。 ## 决策 -`HookDialect` 是封闭的 bridge 集合,`'claude' | 'codex'`;`HookOutput` 移除不受支持的 `suppressOutput`。`hook/result.durationMs` 保留为持久化的审计计时,仅在快照中做归一化。参考默认值各只存在一处:`DEFAULT_HOOK_TIMEOUT_MS` 和 `DEFAULT_STDERR_SUMMARY_MAX_CHARS`。`HookResultRecord` 与 `appendHookResult` 为两个 bridge 统一拥有 stderr 摘要化和 decision 推导逻辑。`BLOCKING_EXIT_CODE` 为 codec 内部常量。 +`HookDialect` 是封闭的 bridge 集合:`'claude' | 'codex'`;`HookOutput` 移除了不受支持的 `suppressOutput`。`hook/result.durationMs` 保留为持久化的审计计时,仅在快照中做归一化。参考默认值各只存在一处:`DEFAULT_HOOK_TIMEOUT_MS` 与 `DEFAULT_STDERR_SUMMARY_MAX_CHARS`。`HookResultRecord` 与 `appendHookResult` 为两个 bridge 统一拥有 stderr 摘要化和 decision 推导逻辑。`BLOCKING_EXIT_CODE` 为 codec 内部常量。 ## 曾考虑的替代方案 -### 为什么不保留? +### 为什么不保留它们? -不受支持的词汇(vocabulary)可以在真正有消费方时回归。`durationMs` 保留,因为持久化的审计计时独立于当前是否有读取者而有价值。Bridge 特有的 payload 构造留在各自 bridge 中,而共享的持久化事件归一化属于协议库。 +不受支持的词汇可以在真正有消费方时回归。`durationMs` 保留,因为持久化的审计计时独立于当前是否有读取方而有价值。Bridge 特有的 payload 构造留在各自 bridge 中,而共享持久化事件的归一化属于协议库。 ## 验证 -`HookDialect` 只包含 Claude 和 Codex,`suppressOutput` 在源码、解析字段文档和归一化逻辑中均不存在。`durationMs` 保留在事件和 fixture(测试前置数据)中,回放时做擦除。`600_000` 和 `500` 默认值各只在协议库中出现一次,per-hook 超时覆盖仍然生效,两个 bridge 的测试套件都验证了库拥有的 stderr 截断和 decision 规则。 +`HookDialect` 仅包含 Claude 和 Codex,`suppressOutput` 在源码、已解析字段文档和归一化逻辑中均不存在。`durationMs` 保留在事件和 fixture(测试前置数据)中,回放时做清洗。`600_000` 和 `500` 两个默认值各只在协议库中出现一次;per-hook 超时覆盖仍然生效;两个 bridge 的测试套件均验证了由库拥有的 stderr 截断和 decision 规则。 ## 后果 -`dialect`、`suppressOutput`、可调参数与语义变更在协议格式(wire format)和 golden 文件上不可见。代价是 `dsh-hook-protocol` 和两个 bridge 的代码变动——在预发布阶段这很廉价,且比让持久化事件语义的两份副本各自老化要廉价得多。 +`dialect`、`suppressOutput`、可调参数与语义的变更在协议格式(wire format)和 golden 文件中均不可见。代价是 `dsh-hook-protocol` 与两个 bridge 的代码变动——在预发布阶段这很廉价,且比让持久化事件语义的两份副本各自老化要廉价得多。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml index f91d18a48e..aa5df52a6a 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-trim-acp-bridge-unreachable-surface.md: 6decb494dcbfd348777577002187007597a8c374 -2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 77e22f75c96704ee0379c45d2ed23167df047324 +2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 9d588e6f1132869ead60586b4df6844400307863 diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md index 77e22f75c9..9d588e6f11 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md @@ -1,26 +1,26 @@ -# RFC:裁剪不可达的 ACP bridge 接口——品牌旋钮与 kind 嗅探回退 - -Status: implemented +# RFC:裁剪不可达的 ACP 桥接层表面——品牌配置项与 kind 嗅探回退 [English](2026-07-04-trim-acp-bridge-unreachable-surface.md) | 中文 +Status: implemented + ## 问题 -`dsh-acp` 有两处接口在任何已交付的配置下都不可达: +`dsh-acp` 有两处对外表面在任何已交付的配置中都不可达: -1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已交付的 app 包(package)只向 bridge 传入 `{ model }`(`packages/examples/acp-demo/src/index.ts`),因此唯一的生产配置面——叶子 `cordis.yml`——根本无法设置这两个旋钮;它们只能通过直接挂载 bridge 来设置,而只有单元测试这样做。所有快照 golden(包括 hook-matrix 场景)都固定了 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对字段还带着一条活跃的 `TODO(double-default)`:字面量存在两份(schema 的 `.default(...)` 加 `??` 回退),TODO 要求选定一个归属。 -2. **`toolKindFor` 名称启发式**(同一文件)在通用回退路径中对 `bash*`/`read*`/`write`/`edit*` 工具名做了特殊处理。自 [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) 以来,这些分支匹配到的每个第一方工具都自带 `presentCall` 并携带其 kind,而没有 presenter 的生产工具(`subagent`、`subagent_fork`)本来就落入 `other`。这些分支在生产中可达的唯一情况是:某个工具拒绝自行呈现其调用——`presentCall` 抛出异常(containment 回退),或模型参数未通过工具 schema 导致 `defineTool` 的 `presentCall` 包装层返回 `undefined`(例如 `bash` 调用缺少必需的 `description`)——而 bridge 自身的模块文档明确声明了该启发式所违反的设计规则:「bridge 从不对工具名做特殊处理」。 +1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已交付的 app 包(`packages/examples/acp-demo/src/index.ts`)只向桥接层传递 `{ model }`,因此没有任何叶子 `cordis.yml`(唯一的生产配置表面)能设置这两个配置项;它们只有通过直接挂载桥接层才能设置,而只有单元测试这样做。所有快照 golden(包括 hook-matrix 场景)都固定了 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对字段还带着一个活跃的 `TODO(double-default)`:字面量存在两份(schema 的 `.default(...)` 加 `??` 回退),TODO 要求选定一个归属。 +2. **`toolKindFor` 名称启发式**(同一文件)在通用回退路径中对 `bash*`/`read*`/`write`/`edit*` 工具名做了特殊处理。自 [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) 以来,这些分支匹配到的每个第一方工具都自带 `presentCall` 并携带其 kind,而没有 presenter 的生产工具(`subagent`、`subagent_fork`)本来就落入 `other`。这些分支只有在工具拒绝自行呈现调用时才在生产中可达:`presentCall` 抛出异常(容错回退),或模型参数未通过工具 schema 导致 `defineTool` 的 `presentCall` 包装层返回 `undefined`(例如 `bash` 调用缺少必需的 `description`)。而桥接层自身的模块文档明确声明了该启发式所违反的设计规则:"桥接层绝不对工具名做特殊处理"。 ## 决策 -在初始化时硬编码现有的握手标识 `{ name: 'deepseek-harness-acp', version: '0.0.1' }`,移除不可达的配置字段与重复默认值。在两处 presenter 回退中,将 `toolKindFor` 替换为中性的 `'other'`。正常的第一方呈现不受影响;格式错误或失败的呈现现在渲染一张诚实的通用卡片,而非从工具名推断 kind。初始化测试和快照固定握手标识;只有 `hook-codex-posttool-block` 中格式错误的调用改变了回退卡片的 kind。 +在初始化时硬编码现有的握手标识 `{ name: 'deepseek-harness-acp', version: '0.0.1' }`,移除不可达的配置字段与重复默认值。在两个 presenter 回退处,将 `toolKindFor` 替换为中性的 `'other'`。正常的第一方呈现不受影响;格式错误或失败的呈现现在会渲染一个诚实的通用卡片,而非从工具名推断 kind。初始化测试和快照固定握手标识;只有 `hook-codex-posttool-block` 中格式错误的调用改变了回退卡片的 kind。 ## 曾考虑的替代方案 ### 为什么不保留? -品牌旋钮可以在 app 包将其暴露给部署时回归。从未知工具名推断呈现方式违反了 render-intent 契约;中性回退卡片还能为格式错误的调用和损坏的 presenter 保留原始输入。 +品牌配置可以在 app 包将其暴露给部署环境时再回来。从未知工具名推断呈现方式违反了 render-intent 契约;中性回退卡片还能为格式错误的调用和损坏的 presenter 保留原始输入。 ## 后果 -除上述回退渲染的取舍外无其他影响——退化路径下,中性卡片比推断出的第一方卡片更易于诊断。 +除上述回退渲染的取舍外没有其他影响——退化路径下,中性卡片比推断出的第一方卡片更易于诊断。 diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml index b423b59448..593cb8a50e 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-drop-unconsumed-skill-provider-events.md: 5ec9d201939b8f58334647353f599361bd2e58a0 -2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 8ab2a5f55b2a1eed676b28a1ca7804d6237f63fd +2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 15dbfedb07afa36b677074c403812d0f164c8bd5 diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md index 8ab2a5f55b..15dbfedb07 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md @@ -1,29 +1,29 @@ # RFC:移除无消费方的 skill 提供方事件 -Status: implemented - [English](2026-07-12-drop-unconsumed-skill-provider-events.md) | 中文 +Status: implemented + ## 问题 -skill(技能)注册表产出了两个通知事件,但在生产代码中没有任何监听方。生成的生产者/消费方矩阵以及精确的事件名搜索表明,`skill/provider-added` 和 `skill/provider-removed` 只出现在声明、发射点、测试、生成目录和行文中。 +skill(技能)注册表产出两个通知事件,但没有生产环境的监听方。生成的生产者/消费方矩阵以及对事件名的精确搜索表明,`skill/provider-added` 与 `skill/provider-removed` 仅出现在声明、emit 站点、测试、生成的 catalog 和行文中。 -skill 发现按需读取当前提供方映射表,提供方注册同步清除已完成的目录缓存,await 后的修订检查防止陈旧的发现结果进入缓存。没有兄弟插件通过这些事件等待 skill 提供方,不同于 `subagent/provider-added` 的实际消费方(它容忍兄弟并发加载)。 +skill 发现按需读取当前的提供方映射表,提供方注册时同步清除已完成的 catalog,而 await 后的版本检查阻止了陈旧的发现结果进入缓存。没有兄弟插件通过这些事件等待 skill 提供方——与之形成对比的是活跃的 `subagent/provider-added` 消费方,它容忍兄弟并发加载。 -`tools/change` 和 `system-prompt/change` 明确不在本提案范围内。既有的简化决策将它们保留为面向实时工具和提示词 UI 的有意观测点,且自引用的已挂载插件已在使用 `tools/change`。本提案同样不改动 `subagent/provider-added`/`removed`,因为 `tool-subagent` 有生产级的生命周期消费方。 +`tools/change` 与 `system-prompt/change` 明确不在本提案范围内。既有的简化决策将它们保留为面向实时工具和提示词 UI 的有意观测点,且自引用的已挂载插件已在使用 `tools/change`。本提案同样不改动 `subagent/provider-added`/`removed`,因为 `tool-subagent` 有生产环境的生命周期消费方。 ## 决策 -skill 注册表不再声明和发射提供方成员变更事件。提供方的注册与 dispose(资源释放)仍为 effect 拥有的直接状态变更,同步使已完成的目录缓存失效;查找与发现按需读取当前提供方映射表。测试通过提供方查找和收集的输出来观察清理行为,而非生命周期通知。 +skill 注册表不再声明和 emit 提供方成员变更事件。提供方的注册与 dispose(资源释放)仍为 effect 所有的直接状态变更,同步使已完成的 catalog 失效;查找与发现按需读取当前提供方映射表。测试通过提供方查找和收集到的输出来观察清理行为,而非依赖生命周期通知。 -生成的事件目录、API 目录与生产者/消费方矩阵不再包含已删除的通知。skill 系统 RFC 和包文档通过 effect 拥有的直接状态及缓存失效契约来描述注册行为。 +生成的事件 catalog、API catalog 与生产者/消费方矩阵不再包含已删除的通知。skill 系统 RFC 与包文档通过 effect 所有的直接状态及缓存失效契约来描述注册行为。 ## 曾考虑的替代方案 -**为未来插件保留 skill 提供方通知。** 第三方插件可能想观察提供方的可用性,但直接提供方注册与按需查找才是扩展契约;当前没有消费方需要推送信号。如果未来出现兄弟加载竞态,可以像 subagent 注册表那样引入一个带有该消费方实际所需的身份与就绪语义的通知。 +**为未来插件保留 skill 提供方通知。** 第三方插件可能想观察提供方的可用性,但直接提供方注册与按需查找才是扩展契约;当前没有消费方需要推送信号。如果将来出现兄弟加载竞态,可以像 subagent 注册表那样,引入一个带有该消费方实际所需的身份与就绪语义的通知。 ## 后果 -生成的事件矩阵中不再有 `skill/provider-added` 或 `skill/provider-removed` 的行。skill 发现、直接运行时注册、提供方 effect 回滚/dispose、缓存失效与注册表查找清理均保留;随事件一起消失的是监听器触发的回滚。`tools/change`、`system-prompt/change` 以及已被消费的 subagent 提供方生命周期事件不受影响。 +生成的事件矩阵中不再有 `skill/provider-added` 或 `skill/provider-removed` 的行。skill 发现、直接运行时注册、提供方 effect 回滚/dispose、缓存失效与注册表查找清理保持不变;监听方触发的回滚随事件一起消失。`tools/change`、`system-prompt/change` 以及已被消费的 subagent 提供方生命周期事件不受影响。 -预发布消费方失去 skill 提供方观测点,但仍保留贡献 skill 的两种方式:直接运行时注册与提供方注册。未来若有消费方需要实时的提供方可用性信息,须新增一个带有其实际所需的身份与就绪语义的专用通知。 +预发布消费方失去 skill 提供方观测点,但仍保留两种贡献 skill 的方式:直接运行时注册与提供方注册。未来若有消费方需要实时的提供方可用性信息,必须新增一个带有其实际所需的身份与就绪语义的专用通知。 diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml index 6c0c1f9108..7413576228 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-prune-unused-web-seam-fields.md: b4773c2706cf6d18ea4bb96720cd6c932cdf8942 -2026-07-12-prune-unused-web-seam-fields.zh.md: 1beb9e597c4b990f2c4aaaf3e34e71027c3f3fb7 +2026-07-12-prune-unused-web-seam-fields.zh.md: 2c18fbcb440ce85798c8f36cdc5dc649149d8ba9 diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md index 1beb9e597c..2c18fbcb44 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md @@ -1,27 +1,27 @@ # RFC:裁剪 web seam 中未使用的字段 -Status: implemented - [English](2026-07-12-prune-unused-web-seam-fields.md) | 中文 +Status: implemented + ## 问题 -web 能力携带了一组 request/result/status 值,每个已交付的实现都填充了它们,但没有生产消费方读取。`WebSearchResult.providerId`、`query` 和 `WebFetchResult.providerId` 是结果回显;`tool-web` 只格式化 content/sources/truncation 或 final URL/status/body/truncation,其他运行时也不读取这些字段。搜索提供方返回 `WebProviderStatus.reason`,但可用性检查只看 `available`,并有意输出一条通用的不可用诊断。 +web 能力携带的 request/result/status 值,虽然每个已交付的实现都会填充,但没有任何生产环境的消费方读取它们。`WebSearchResult.providerId`、`query`与 `WebFetchResult.providerId` 是结果回显;`tool-web` 只格式化 content/sources/truncation 或最终 URL/status/body/truncation,没有其他运行时读取这些字段。搜索提供方返回 `WebProviderStatus.reason`,但可用性检查只看 `available`,并有意输出一条通用的不可用诊断信息。 -`WebFetchRequest.timeoutMs` 同样没有生产调用方设置。`tool-web` 只提供 URL,用工具定义的超时加 `exec.signal` 作为调用方截止时间,并依赖本地提供方的配置默认值作为兜底。这个未使用的按请求超时覆盖迫使 `web-fetch-local` 暴露 `maxTimeoutMs`、钳位两个超时源,并为没有产品路径能选中的优先级规则编写文档和测试。`WebExecContext` 则是另一个单字段包装层:每个调用方分配 `{ signal }`,每个提供方立即解包 `exec?.signal`;不存在第二个执行控制字段。 +`WebFetchRequest.timeoutMs` 同样从未被生产调用方设置。`tool-web` 只提供 URL,使用工具定义的 timeout 加 `exec.signal` 作为调用方截止时间,并依赖本地提供方的配置默认值作为兜底。这个未使用的逐请求覆盖迫使 `web-fetch-local` 暴露 `maxTimeoutMs`、对两个 timeout 来源做 clamp,并为没有任何产品路径能选中的优先级规则编写文档和测试。`WebExecContext` 则是另一个单字段包装层:每个调用方分配 `{ signal }`,每个提供方立即解包 `exec?.signal`;不存在第二个执行控制字段。 ## 决策 -web seam 省略搜索/抓取的 `providerId` 结果回显和搜索 `query` 回显;调用方本身已持有请求和提供方选择信息。提供方以返回布尔值的方法暴露可用性。抓取请求不再有按请求超时或 `maxTimeoutMs` 钳位;本地提供方保留其可配置的默认超时,工具保留自身的截止时间。提供方方法接收一个直接的可选 `AbortSignal`,而非单字段的 `WebExecContext` 包装层。 +web seam 移除搜索/抓取结果中的 `providerId` 回显和搜索的 `query` 回显;调用方本身已持有请求和提供方选择信息。提供方以返回布尔值的方法暴露可用性。抓取请求不再有逐请求 timeout 或 `maxTimeoutMs` clamp;本地提供方保留其可配置的默认 timeout,工具保留自身的截止时间。提供方方法直接接收一个可选的 `AbortSignal`,而非单字段的 `WebExecContext` 包装层。 -所有 web 实现和面向模型的工具使用更小的契约。接口/实现/消费方的包拆分、提供方选择、来源引用、最终 URL/状态数据、截断报告与安全限制保持不变。 +所有 web 实现与面向模型的工具使用更精简的契约。接口/实现/消费方的包(package)拆分、提供方选择、来源引用、最终 URL/状态数据、截断报告与安全限制保持不变。 ## 曾考虑的替代方案 -**保留自描述结果、按请求截止时间和可扩展的执行上下文对象。** 结果回显可以帮助通用遥测,请求超时可以帮助受信的编程调用方,包装层对象为未来的控制留出空间。但这样的消费方或第二字段并不存在;在每个提供方中携带重复的身份信息、第二套截止时间策略以及包装/解包管道,使当前契约更难实现和解释。如果遥测或按调用的预算控制到来,它应当定义哪个截止时间获胜、在哪里观测提供方身份,以及多个控制是否足以证明上下文对象的存在。 +**保留自描述结果、逐请求截止时间与可扩展的执行上下文对象。** 结果回显可以帮助通用遥测,请求级 timeout 可以帮助受信的程序化调用方,包装层则为未来的控制字段留出空间。但目前不存在这样的消费方或第二个字段;在每个提供方中携带重复的身份标识、第二套截止时间策略以及包装/解包管道,使当前契约更难实现和解释。如果遥测或逐调用预算控制到来,届时应当定义哪个截止时间优先、在哪里观测提供方身份,以及多个控制字段是否足以证明需要一个上下文对象。 ## 后果 -保留下来的每个 web request/result 字段都被生产代码消费或为执行提供方请求所必需。工具可见的搜索/抓取输出、提供方回退、中止行为、配置的超时兜底、截断与引用仍被覆盖,无需请求超时优先级分支或执行上下文包装层。 +保留下来的每个 web request/result 字段,要么被生产代码消费,要么是执行提供方请求所必需的。工具可见的搜索/抓取输出、提供方回退、中止行为、可配置的 timeout 兜底、截断与引用仍然被覆盖,无需请求级 timeout 优先级分支或执行上下文包装层。 -预发布的编程调用方失去结果来源回显和按请求的抓取截止时间。提供方仍有部署可配置的超时并尊重取消信号,因此这次精简移除的是可配置性而非安全边界。 +预发布阶段的程序化调用方失去了结果来源回显和逐请求的抓取截止时间。提供方仍具备部署级可配置 timeout 并尊重取消信号,因此这次精简移除的是可配置性,而非安全边界。 diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml index 34850593d6..cc2bf1f23f 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-property-based-testing.md: 169989746ea5114b1f35e7ebe35a02e8aeb0f782 -2026-06-11-property-based-testing.zh.md: 9e1532c02bb2c46c40577af7275d6aabbb2f9a4f +2026-06-11-property-based-testing.zh.md: 4f2303010f44279c4edd0ec5a509b71f8aad606b diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md index 9e1532c02b..4f2303010f 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md @@ -4,26 +4,26 @@ Status: implemented [English](2026-06-11-property-based-testing.md) | 中文 -> 将原始提案与决策记录合并为一篇。首次运行即发现了 BlockAssembler 的重复 `block-end` 真实 bug。 +> 将原始提案与同一主题的决策记录合并为一篇。首次运行即发现了 BlockAssembler 重复 `block-end` 的真实 bug。 ## 问题 -基于示例的测试只能固定我们想到的用例。harness 的核心是协议形态的代码:分片流、事件日志、schema 转换、收件箱调度。这类代码的输入空间是组合爆炸的,有趣的 bug 藏在没人写过示例的交错序列里。佐证:一个 block 组装的排序 bug 曾在 happy path 100% 行覆盖率下存活。逐文件 100% 覆盖率只能证明每行都跑过,不能证明每种交错都正确。 +基于示例的测试只能固定我们想到的用例。harness 的核心是协议形态的代码:分片流、事件日志、schema 转换、收件箱调度。这些场景的输入空间是组合式的,有趣的 bug 藏在没人写过示例的交错序列中。佐证:一个块组装的排序 bug 曾在 happy path 100% 行覆盖率下存活。逐文件 100% 覆盖率证明每一行都跑过了,但不能证明每种交错都是正确的。 ## 决策 -引入 `fast-check`(根 devDependency),在每个协议形态的包中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。(原始提案还草拟了一个夜间 CI job,以 100 倍迭代运行;该部分未交付——属性测试套件仅在常规 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。) +引入 `fast-check`(作为根 devDependency),在每个协议形态的包(package)中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。(原始提案还草拟了一个夜间 CI job,以 100 倍迭代运行;该部分未交付。属性测试套件仅在常规的 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。) -- **dsh-llm / BlockAssembler:** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start)。不变式:`blocks()` 数量 ≤ 出现过的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且只产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无 `finish` 分片时默认为 `{kind:'stop'}`。 -- **dsh-session:** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致;seq 严格单调递增;非消息事件不影响派生历史;派生内容与日志解耦。 -- **dsh-tools:** 任意 `SchemaSpec`。不变式:JSON Schema 的 `required` 等于每层 `required:true` 的键集合;转换是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`,定向破坏(删除 required 键、顶层非 object)被拒绝。这封堵了 validator 与 `InferArgs` 漂移的风险。 -- **dsh-agent-loop:** 任意发送调度,对接一个永不耗尽的适配器,通过 `agent/status` settle 信号驱动(无挂钟 sleep)。不变式:无消息丢失;轮次编号严格递增;状态转换始终在合法状态机上。 +- **dsh-llm / BlockAssembler:** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start)。不变式:`blocks()` 计数 ≤ 已见到的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且仅产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无 `finish` 分片时默认为 `{kind:'stop'}`。 +- **dsh-session:** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致;seq 严格单调递增;非消息事件不影响推导出的历史;推导出的内容与日志解耦。 +- **dsh-tools:** 任意 `SchemaSpec`。不变式:JSON Schema 的 `required` 等于每一层 `required:true` 的键集;转换是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`,而定向破坏(删除必填键、顶层非对象)被拒绝。这封堵了 validator 与 `InferArgs` 漂移的风险。 +- **dsh-agent-loop:** 任意发送调度,对接一个永不耗尽的适配器,通过 `agent/status` settle 信号驱动(无挂钟 sleep)。不变式:无消息丢失;轮次编号严格递增;状态转换保持在合法状态机上。 ## 后果 -- 生成器质量是价值杠杆——生成器偏向小索引池和短字符串,使碰撞与交错频繁出现。 -- **已经产出回报:** BlockAssembler 的流测试发现了一个真实 bug——同一索引的重复 `block-end` 覆写了已刷出的块,导致流式前缀与最终 `blocks()` 不一致。已修复(首次关闭生效,与既有的滞后分片规则一致),并附带专门的回归测试。 -- 属性测试因超时而 flake 是一个发现,不应重试了事。agent loop 的属性测试在设计上是确定性的(通过 `agent/status` settle),因此挂起即为真实缺陷。 -- 属性测试是示例测试的补充而非替代;示例测试固定特定分支,服务于 100% 覆盖率门禁。 +- 生成器质量是价值杠杆——生成器偏向小索引池和短字符串,使碰撞与交错频繁发生。 +- **已经产出回报:** BlockAssembler 流测试发现了一个真实 bug——同一索引的重复 `block-end` 覆盖了已刷出的块,导致流式前缀与最终 `blocks()` 不一致。已修复(首次关闭生效,与既有的滞后分片规则一致),并附带一个专门的回归测试。 +- 属性测试因超时而 flake 是一个发现,不应通过重试消除。循环属性测试在设计上是确定性的(通过 `agent/status` settle),因此挂起即为真实缺陷。 +- 属性测试是对示例测试的补充而非替代;示例测试固定特定分支,服务于 100% 覆盖率门禁。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 8014cd6b63..1dfb26a628 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-acp-snapshot-tests.md: 0b93c99932a33bca9945dd88ce45f4a1e100ccfc -2026-06-19-acp-snapshot-tests.zh.md: dc0aeb102039d3261ad6bbbb21321ca2b6ef5ec3 +2026-06-19-acp-snapshot-tests.zh.md: 26c583ba47b8ec10ab3d0e2102a8b791549fda38 diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index dc0aeb1020..26c583ba47 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -单元测试无法覆盖完整的 ACP 子进程 transcript(文本记录),而真实 API 测试既不确定又依赖密钥。因此,面向编辑器的 `session/update` 输出可能在单元覆盖率全绿的情况下发生回归,正如 [default-export 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)所展示的那样。 +单元测试无法覆盖完整的 ACP(Agent Client Protocol)子进程 transcript(文本记录),而真实 API 测试既不确定又需要密钥。因此,面向编辑器的 `session/update` 输出可能在单元覆盖率全绿的情况下发生回归,正如 [default-export 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)所揭示的那样。 -全 transcript 测试的阻塞点在于模型:agent(智能体)的输出由非确定性的 LLM(大语言模型)驱动,而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度,同时具备 fixture(测试前置数据)的确定性。 +全 transcript 测试的阻塞因素在于模型:agent 的输出由非确定性的 LLM(大语言模型)驱动,而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度与 fixture(测试前置数据)的确定性兼得。 -本 RFC 记录了添加第三层测试——**快照测试**——的决策,以及使其确定、CI 中无需密钥且维护成本低的设计选择。 +本 RFC 记录了新增第三层测试——**快照测试**——的决策,以及使其具备确定性、CI 中无需密钥、维护成本低的设计选择。 ## 决策 @@ -18,11 +18,11 @@ Status: implemented ### fixture 即持久化的会话 JSONL -每个场景的 `session.jsonl` 从一次真实运行中采集。`assistant/chunk` 事件重现模型流;工具、消息和边界事件捕获 harness 行为。一份普通的会话产物因此同时充当回放源和行为 golden。 +每个场景的 `session.jsonl` 从一次真实运行中采集。`assistant/chunk` 事件重现模型流;tool、message 和 boundary 事件捕获 harness 行为。一份普通的会话产物因此同时充当回放源和行为 golden。 ### 回放从日志推导模型脚本 -`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。循环每步发起一次流调用,因此分组是精确的,且无需特殊处理即可包含 error finish chunk。 +`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。agent loop(智能体循环)每个 step 发起一次流调用,因此分组精确对应,错误结束 chunk 也无需特殊处理。 ### 内存中的回放条目遵守完整的 LLM 契约 @@ -34,32 +34,32 @@ Status: implemented | { kind: 'hang' } ``` -日志推导出 chunk 条目。流开始前的抛出和挂起没有可重建的 chunk 表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀 chunk 以表示流中途失败。显式覆盖避免了从有损的 turn-end reason 推断适配器行为。 +日志推导出 chunk 条目。流开始前的抛出和挂起没有可重建的 chunk 表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀 chunk 以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。 ### 位置式回放,单个在途流 -回放是位置式的,因此每个场景只允许一个在途模型流。并发会话快照需要按请求键索引的条目。调用顺序变化需要重新录制,fixture 缺失或耗尽时会大声失败。 +回放是位置式的,因此每个场景只允许一个在途模型流。并发会话快照需要按请求键索引的条目。调用顺序变更需要重新录制,fixture 缺失或耗尽时立即报错。 ### 录制采集日志;无密钥回放需要无提供方的配置 -录制使用真实的 `llm-deepseek` 适配器和 JSONL 持久化后端运行场景,然后将产出的 `.jsonl` 复制到场景目录。逐事件追加是持久的,但 harness 在采集前会优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),确保最终事件已刷出。`llm-replay` 本身不做录制——它只负责回放。 +录制使用真实的 `llm-deepseek` 适配器和 JSONL 持久化后端运行场景,然后将产出的 `.jsonl` 复制到场景目录。逐事件追加是持久的,但 harness 在采集前会优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),确保最终事件已刷盘。`llm-replay` 本身不做录制,它只负责回放。 -回放使用 `cordis.snapshot.yml` 覆盖层,将真实适配器替换为 `llm-replay`,同时保留活跃的组合。录制使用普通配置和 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此一个意外存在的 API key 不会触发真实调用。见[单一源配置 RFC](2026-07-04-single-source-acp-replay-config.md)。 +回放使用 `cordis.snapshot.yml` 覆盖配置,将真实适配器替换为 `llm-replay`,同时保留活跃的组合。录制使用普通配置和 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此一个意外存在的 API key 不会触发真实调用。见[单源配置 RFC](2026-07-04-single-source-acp-replay-config.md)。 ### 两个表面:归一化后比对 快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的: -1. **stdout transcript**——编辑器看到的帧化 `session/update` JSON-RPC。捕获 ACP bridge 的事件→update 转换(`streamSessionEventUpdate`)中的回归。与已提交的 `stdout.golden.jsonl` 比对。 -2. **重新持久化的会话 JSONL**,归一化后与 `session.jsonl` 比对。同一份 fixture 既是回放源也是期望日志。提示词文本被擦除;每个 header 类别一个场景固定可读的 prompt 和工具内容,见 [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)。覆盖场景的模型行为完全来自其伴随记录。 +1. **stdout transcript**——编辑器看到的带帧 `session/update` JSON-RPC。捕获 ACP bridge 事件→update 转换(`streamSessionEventUpdate`)中的回归。与已提交的 `stdout.golden.jsonl` 比对。 +2. **重新持久化的会话 JSONL**,归一化后与 `session.jsonl` 比对。同一份 fixture 既是回放源也是预期日志。提示词文本被擦除;每个 header 类别一个场景固定可读的 prompt 和 tool 内容,见 [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)。覆盖场景的模型行为完全来自其伴随文件。 -两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的循环、工具和边界结构。 +两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的 loop、tool 和 boundary 结构。 -归一化替换会话 ID、cwd、protocol-id、时间戳、路径和进程易变值,同时保留确定性序列号。场景将真实 bash 使用限制在稳定命令上。stdout golden 保持协议格式(wire format)的 JSONL,每行原始数据必须能解析为 JSON。Vitest 只更新 stdout golden;归一化后的会话相等性检查从不覆写回放 fixture。 +归一化替换 session、cwd、protocol-id、时间戳、路径和进程相关的易变值,同时保留确定性序列号。场景将真实 bash 使用限制在稳定命令范围内。stdout golden 保持协议格式(wire format)的 JSONL,每一行原始数据必须可解析为 JSON。Vitest 只更新 stdout golden;归一化后的会话相等性检查从不覆盖回放 fixture。 -### 隔离:当前靠归一化,后续可沙箱 +### 隔离:当前靠归一化,后续可加沙箱 -工具确定性来自临时 cwd、擦除的环境变量、全新的非登录 shell、受限命令和归一化。它不声称具备 OS 级隔离。如果需要更强的层级,沙箱执行器可以通过既有的[能力 seam](../architecture/2026-06-13-capability-seams.md) 替换本地后端。 +工具的确定性来自临时 cwd、擦除的环境变量、全新的非登录 shell、受限命令和归一化。它不声称具备操作系统级隔离。如果需要更强的隔离层级,可通过既有的[能力 seam](../architecture/2026-06-13-capability-seams.md) 将沙箱执行器替换本地后端。 ### 回放插件是独立的包 @@ -67,16 +67,16 @@ Status: implemented ### 两个子命令,回放在默认门禁中 -`pnpm run test:snapshot` 无密钥回放已提交的 fixture;`test:snapshot:record` 使用真实 API 并重写采集到的会话日志和 stdout golden。fixture 缺失时大声失败。每个场景携带 `input.json`、`stdout.golden.jsonl` 和 `session.jsonl`;无模型场景使用仅含 header 的日志。`replay.override.json` 仅在标记为 `overridden` 的场景中必需,因为它的存在会替换推导出的回放。fixture 守卫拒绝缺失、不匹配和遗留的文件。两个命令都接受场景过滤器。 +`pnpm run test:snapshot` 无需密钥地回放已提交的 fixture;`test:snapshot:record` 使用真实 API 并重写采集到的会话日志和 stdout golden。fixture 缺失时立即报错。每个场景携带 `input.json`、`stdout.golden.jsonl` 和 `session.jsonl`;无模型场景使用仅含 header 的日志。`replay.override.json` 仅在标记为 `overridden` 的场景中必需,因为它的存在会替换推导出的回放。fixture 守卫拒绝缺失、不匹配和遗留的文件。两个命令均接受场景过滤器。 ## 曾考虑的替代方案 -- **手写的模型 chunk `llm.json`**:早期草案;复用真实会话日志使 fixture 成为系统的真实产物而非手工构建的 mock,并兼作行为 golden。 -- **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。适配器相关、与流式 SSE 配合笨拙,且层级低于被测对象。 -- **从 `turn/end {kind:'error'|'aborted'}` 合成 throw/cancel 条目**:否决。这会将 `llm-replay` 耦合到循环内部的 turn 关闭语义,且 `turn/end` reason 是有损的(无法区分抛出的 401 和 finish-error);显式的 `replay.override.json` 伴随记录是更干净的 seam。 +- **手工编写的模型 chunk `llm.json`**:早期草案的做法。复用真实会话日志使 fixture 成为系统的真实产物而非手工构建的 mock,并兼作行为 golden。 +- **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 +- **从 `turn/end {kind:'error'|'aborted'}` 合成 throw/cancel 条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 ## 后果 -新层级为每个场景添加经评审的 input、session、stdout、可选 override 和可选 workspace fixture。workspace 种子在录制和回放时都被复制到临时 cwd。作为回报,该层级通过真实的 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、input、workspace、归一化和回放 harness 可以支持 ACP 以外的示例。 +新测试层为每个场景增加了经评审的 input、session、stdout、可选 override 和可选 workspace fixture。workspace 种子在录制和回放时都会被复制到临时 cwd。作为回报,该层通过真实的 Loader 和 tool 组合提供确定性的无密钥 transcript 覆盖。子进程、input、workspace、归一化和回放 harness 可以支持 ACP 之外的示例。 -本 RFC 与[拟议的确定性 RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) 相关但不取代它:该提案的「通用回放 fixture」在每次测试后重新推导会话*消息历史*(一个内部一致性不变式),而快照测试固定的是*外部协议输出*。二者互补:一个守护事件溯源不变式,另一个守护面向编辑器的契约。 +本 RFC 与[拟议的确定性 RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) 相关但不取代它:该提案的「通用回放 fixture」在每次测试后重新推导会话的*消息历史*(一项内部一致性不变式),而快照测试固定的是*外部协议输出*。二者互补:一个守护事件溯源不变式,另一个守护面向编辑器的契约。 diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index fadd3e4a52..591f51750a 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-real-api-e2e-ci.md: 3b5995a3e060ef9b4b1639b5c7fb17c819e73150 -2026-06-19-real-api-e2e-ci.zh.md: 4d4b38cd5989426482b773da79b3a44cec90f747 +2026-06-19-real-api-e2e-ci.zh.md: 78bab7c1e00125bfbe11b8f08eeeff3f2b7723b1 diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 4d4b38cd59..78bab7c1e0 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -1,100 +1,100 @@ # RFC:在 CI 中对外部 DeepSeek API 运行真实 API e2e 测试 -Status: implemented - [English](2026-06-19-real-api-e2e-ci.md) | 中文 +Status: implemented + ## 问题 -按照既定策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../testing.md) 论证了无密钥测试套件只能验证管道连通性而非产品行为,[ACP inject 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)是现成的证据——178 个无密钥测试全绿,而真实编辑器会话一启动就崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)正是为了弥合这一缺口:它驱动 agent 对接线上 DeepSeek API——真实模型调用、真实 bash 工具、多轮对话、恢复、ACP-over-stdio。 +按照策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../testing.md) 论证了无密钥套件只能验证管道连通性而非产品本身,[ACP inject 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)是现成的证据——178 个无密钥测试全绿,而真实编辑器会话一启动就崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)正是为弥合这一差距而存在的:它驱动 agent(智能体)对接实时 DeepSeek API——真实模型调用、真实 bash 工具、多轮次对话、恢复、ACP-over-stdio。 -默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意不携带密钥:它不含 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此把它加到 ci.yml 只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。 +默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。 -本 RFC 记录的决策是:新增一个**第二个、消费 secret 的工作流**来在 CI 中运行真实 API 套件。同时,由于这是向一个未来可能公开的仓库引入首个 CI secret,属于安全/隔离决策,本文一并记录其依赖的威胁模型以及仓库公开后会发生什么变化。 +本 RFC 记录的决策是:添加一个**第二个、消费 secret 的工作流**来在 CI 中运行真实 API 套件。由于这是向一个未来可能公开的仓库引入首个 CI secret,属于安全/隔离决策,本文同时记录其依赖的威胁模型以及仓库公开后的变化。 ## 决策 -新增专用工作流 [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml),与 ci.yml 分离。它仅在受信事件上使用仓库 secret 对外部 API 运行 `pnpm run test:e2e`,并设有预检步骤:secret 缺失时以显式失败替代假绿。无密钥工作流保持独立,使可 fork 的质量门禁与消费 secret 的真实 API 门禁各自拥有不同的触发和凭证策略。 +添加一个专用工作流 [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml),与 ci.yml 分离。它仅使用 repo secret 对外部 API 运行 `pnpm run test:e2e`,仅在可信事件上触发,并带有一个 preflight 检查:将缺失的 secret 转化为明确的失败而非虚假的绿色。无密钥工作流保持独立,使可 fork 的质量门禁与消费 secret 的真实 API 门禁各自拥有不同的触发和凭证策略。 ### 独立工作流,而非 ci.yml 中的一个 job -ci.yml 的价值在于它无密钥、可 fork、始终绿色:任何贡献者(包括外部 fork)都能获得完整的无密钥信号,secret 不在其爆炸半径内。在那里添加消费 secret 的 job 会将这个始终绿色的门禁耦合到凭证可用性和不同的触发策略上。将携带 secret 的工作放在独立文件中,隔离了 secret、触发和并发策略,并为 fork 保留了 ci.yml 的特性。不同的生命周期 → 不同的文件。 +ci.yml 的价值在于它无密钥、可 fork、始终为绿:任何贡献者(包括外部 fork)都能获得完整的无密钥信号,secret 不在爆炸半径内。在其中添加消费 secret 的 job 会将这个始终为绿的门禁耦合到凭证可用性和不同的触发策略上。将携带 secret 的工作放在独立文件中,隔离了 secret、触发和并发策略,并为 fork 保留了 ci.yml 的特性。不同的生命周期→不同的文件。 -### 成本不是约束,可靠性才是 +### 约束不是成本,而是可靠性 -内部推理成本不是限制因素,因此工作流以覆盖率和信号为优化目标。它在多种触发条件和每个受信 PR 上运行所有匹配的 `*.e2e.ts` 文件,落实 [docs/testing.md](../../../testing.md) 的 with-key 策略。 +内部推理(inference)成本不是限制因素,因此工作流以覆盖率和信号为优化目标。它在多个触发条件和每个可信 PR(Pull Request)上运行所有匹配的 `*.e2e.ts` 文件,落实 [docs/testing.md](../../../testing.md) 的有密钥策略。 -### 触发条件:仅受信事件 +### 触发条件:仅限可信事件 -`workflow_dispatch` + `push` 到 `main`/`master` + 每日定时 `schedule`(`17 0 * * *`,即北京时间 08:17)+ `pull_request`。push 提供合并后信号;schedule 捕获外部 API 漂移;dispatch 是手动逃生口;受信 pull request 获得合并前门禁。该合并前信号有意接受 § 安全性 中描述的更大密钥暴露面。 +`workflow_dispatch` + `push` 到 `main`/`master` + 每夜 `schedule`(`17 0 * * *`,即北京时间 08:17)+ `pull_request`。push 提供合并后信号;schedule 捕捉外部 API 漂移;dispatch 是手动逃生通道;可信 pull request 获得合并前门禁。该合并前信号有意接受 § 安全性中描述的更大密钥暴露面。 -### 不受信 PR 的门禁 +### 不可信 PR 的门禁 -GitHub 对两类 PR 隐藏仓库 secret:来自 **fork** 的 PR,以及 **Dependabot** PR(同仓库分支,因此 `head.repo.fork == false`,但 secret 仍被隐藏)。job 级 `if:` 对两者都跳过整个 job: +GitHub 对两类 PR 扣留 repo secret:来自 **fork** 的 PR,以及 **Dependabot** PR(同仓库分支,`head.repo.fork == false`,但 secret 仍被扣留)。一个 job 级 `if:` 对两者都跳过整个 job: ``` github.event_name != 'pull_request' || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]') ``` -Dependabot 子句基于 PR **作者**(`pull_request.user.login`)而非 `github.actor`(运行触发者):维护者重新打开或重跑 Dependabot PR 时,`github.actor` 会变成人类,但 PR 仍然无密钥;基于作者的判断在这种情况下依然正确。被 **job 级** `if:` 跳过的 job 报告为*成功*检查(不同于工作流/触发级跳过,后者保持 pending),因此如果需要,可以安全地将此工作流标记为 required status check——fork/Dependabot PR 的跳过但绿色的检查不会阻塞合并。 +Dependabot 子句基于 PR **作者**(`pull_request.user.login`)而非 `github.actor`(运行触发者):维护者重新打开或重跑 Dependabot PR 时,`github.actor` 会变成人类,但该 PR 仍然无密钥;基于作者的判断在这种情况下依然正确。被 **job 级** `if:` 跳过的 job 报告为*成功*检查(不同于工作流/触发级跳过会保持 pending),因此如果需要将此工作流标记为 required status check 也是安全的——fork/Dependabot PR 的跳过但绿色的检查不会阻塞合并。 -该门禁是一个*干净跳过的便利措施*,而非 secret 的安全边界(见 § 安全性——边界是 GitHub 自身在 `pull_request` 下对 fork 的 secret 隐藏机制)。没有这个门禁,fork 仍然无法读取密钥;它们只会遇到一个令人困惑的预检硬失败并浪费计算资源。 +该门禁是一个*干净跳过的便利措施*,而非 secret 的安全边界(见 § 安全性——边界是 GitHub 自身在 `pull_request` 下对 fork 的 secret 扣留机制)。没有该门禁,fork 仍然无法读取密钥;只是会遇到令人困惑的 preflight 硬失败并浪费计算资源。 -### 预检:大声失败,绝不假绿 +### Preflight:大声失败,绝不虚假为绿 -由于 job 仅在 secret 预期存在的受信事件上运行,预检是无条件的存在性检查:密钥为空 → `exit 1` 并附带 `::error::` 注解指明需要配置的 secret 名称。这是让自跳过套件可以安全用作门禁的关键。没有它,被删除/重命名/配置错误的 secret 会让 `test:e2e` 跳过所有真实套件并报告全绿——整个安全网的静默退化。这个守卫将「secret 缺失」从不可见的假通过变为可见的失败。(其正确性已在实际中验证:secret 存在之前的运行恰好在此步骤失败。) +由于 job 仅在 secret 应当存在的可信事件上运行,preflight 是一个无条件的存在性检查:密钥为空→`exit 1` 并附带 `::error::` 注解指明需要配置的 secret 名称。这是让自跳过套件可以安全地作为门禁的关键。没有它,被删除/重命名/错误配置的 secret 会让 `test:e2e` 跳过所有真实套件并报告全绿——整个安全网的静默退化。该守卫将「secret 缺失」从不可见的虚假通过转化为可见的失败。(其正确性已在实际中验证:secret 存在之前的运行恰好在此步骤失败。) ### Secret 映射与卫生 -仓库 secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;它被映射到适配器和测试读取的 `DEEPSEEK_API_KEY` 环境变量(`process.env.DEEPSEEK_API_KEY`)。独立的 secret 名称记录了意图(这是*外部*公开 API 密钥,不是内部端点密钥),并允许内部端点密钥日后无冲突地共存。以下卫生选择均为防御性设计: +repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试读取的 `DEEPSEEK_API_KEY` 环境变量(`process.env.DEEPSEEK_API_KEY`)。独立的 secret 名称记录了意图(这是*外部*公开 API 密钥,不是内部端点密钥),并允许内部端点密钥日后无冲突地共存。以下卫生选择均为防御性设计: -- **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在预检和 e2e 步骤的 `env:` 中设置,绝不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 -- **`permissions: contents: read`。** 该 job 仅读取仓库以运行测试;不需要写权限(不写 PR 评论、不写 status),因此 `GITHUB_TOKEN` 降至最小权限。 -- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) 中的 `PUBLIC_BASE_URL`),但显式固定具有自文档化和密封性——一个意外的仓库根目录 `.env`(`vitest.e2e.config.ts` 如果存在会加载它)无法静默地将运行重定向到其他端点。 -- **不回显 secret。** 预检仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 +- **Step 级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 +- **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。 +- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(`vitest.e2e.config.ts` 存在时会加载)无法静默地将运行重定向到其他端点。 +- **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 ### 范围与运行时形态 -该 job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界可配置的 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和定时运行完整执行以提供合并后信号。 +job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和 schedule 运行完整执行以提供合并后信号。 ## 安全性 -仓库的首个 CI secret 需要一份记录在案的威胁模型,因为同仓库 PR、fork PR 和 Dependabot PR 之间的访问权限不同,且仓库公开后会发生变化。 +仓库的首个 CI secret 需要一份记录在案的威胁模型,因为同仓库 PR、fork PR 和 Dependabot PR 的访问权限各不相同,且仓库公开后会发生变化。 -### 今天谁能触及 secret(私有仓库) +### 当前谁能触及 secret(私有仓库) -- **无写权限(fork PR):不能。** 两个独立事实阻止了它。第一,工作流使用 `pull_request` 而**非** `pull_request_target`——GitHub 不会将仓库 secret 传递给 fork PR 的 `pull_request` 运行,因此 `secrets.DEEPSEEK_API_KEY_EXTERNAL` 在 fork runner 上解析为空。第二,`if:` 门禁完全跳过 fork PR。secret 隐藏机制是真正的边界;门禁是纵深防御和用户体验。 -- **写(push)权限:能。** 同仓库分支 PR 会收到 secret,因此有写权限的作者可以修改测试代码(或安装生命周期脚本,或其分支上的工作流 YAML)来窃取密钥。这是 **GitHub Actions 固有的,并非本文引入的**:任何对任何仓库有 push 权限的人都可以通过编写工作流来窃取该仓库的任何 Actions secret。写权限 ⇒ secret 访问权,始终如此。缓解措施在于谁被授予写权限以及分支保护,而非本文件。 +- **无写权限(fork PR):不能。** 两个独立事实阻止了它。第一,工作流使用 `pull_request` 而**非** `pull_request_target`——GitHub 不会将 repo secret 传递给 fork PR 的 `pull_request` 运行,因此 `secrets.DEEPSEEK_API_KEY_EXTERNAL` 在 fork runner 上解析为空。第二,`if:` 门禁完全跳过 fork PR。secret 扣留是真正的边界;门禁是纵深防御和用户体验。 +- **有写(push)权限:能。** 同仓库分支 PR 会收到 secret,因此有写权限的作者可以修改测试代码(或安装生命周期脚本,或其分支上的工作流 YAML)来窃取密钥。这**是 GitHub Actions 的固有特性,并非本文引入的**:任何对任何仓库有 push 权限的人都可以通过编写工作流来窃取该仓库的任何 Actions secret。写权限⇒secret 访问权,始终如此。缓解措施在于谁被授予写权限以及分支保护,而非本文件。 -因此「任何能开 PR 的人都能窃取它」是错误的:只有写权限集合内的人能,而该集合本来就能窃取仓库持有的任何 secret。 +因此「任何能开 PR 的人都能窃取它」是错误的:只有写权限集合内的人能,而这些人本来就能窃取仓库持有的任何 secret。 ### `pull_request` 触发器增加的残余暴露面 -由于启用了 PR 运行,密钥会在合并前被交给**写权限作者 PR 分支上的代码**。这比 `push` + `schedule` + `workflow_dispatch` 的暴露面更大,为了在受信写权限集合内获得合并前信号而被接受。如果这一权衡发生变化,可以去掉 `pull_request` 触发器,同时保留合并后、每夜和按需覆盖。 +由于启用了 PR 运行,密钥会在合并前被交给**写权限作者 PR 分支上的代码**。这比 `push` + `schedule` + `workflow_dispatch` 的暴露面更大,为在可信写权限集合内获得合并前信号而接受。如果这一权衡发生变化,可移除 `pull_request` 触发器,同时保留合并后、每夜和按需覆盖。 -### 仓库公开后会发生什么变化 +### 仓库公开后的变化 -**通过本工作流**,secret 对公众仍然受保护:`pull_request` 在公开仓库上行为一致——fork PR(现在任何人都能开)仍然收不到 secret,且在公开仓库上 GitHub 额外要求维护者批准 fork PR 运行,即使批准后运行也不会获得 secret(批准运行不等于交出密钥)。写权限集合不因可见性改变,因此内部人员的现实也不变。 +**通过本工作流**,secret 对公众仍然受保护:`pull_request` 在公开仓库上行为一致——fork PR(现在任何人都能开)仍然收不到 secret,且在公开仓库上 GitHub 额外要求维护者批准 fork PR 运行,即使批准后运行也不会获得 secret(批准运行不等于交出密钥)。写权限集合不因可见性改变而改变,因此内部人员的现实也不变。 变差的是*周边*模型,以下是翻转可见性之前需要处理的事项: -- **日志变为全球可读。** 今天泄露给组织成员的粗心 secret 回显,在公开后会泄露给整个互联网并在几分钟内被爬取。secret 处理纪律(不回显值/长度——已完成)的重要性大幅提升。 -- **`pull_request_target` 陷阱变为灾难性的。** 如果有人为了「修复」PR 运行而将触发器切换为 `pull_request_target`,工作流将在 base 仓库上下文中运行不受信的 fork 代码**并携带** secret——完整的密钥泄露向量。这在私有仓库上尚可容忍,在公开仓库上则是灾难。e2e.yml 中触发器上的 `SECURITY —` 注释禁止此更改并指向本文。 -- **翻转时轮换密钥。** 该密钥曾存在于私有仓库的 CI 中;将公开视为「假设已暴露」,在那一刻轮换 `DEEPSEEK_API_KEY_EXTERNAL`。 -- **将 secret 置于控制之下。** 确认 Settings → Actions → *"Send secrets to workflows from fork pull requests"* 保持**关闭**(这是唯一能真正打破 fork 边界的设置),并考虑将密钥移入带有 required reviewers 的 GitHub **Environment**,使即使已合并的代码也只在受控条件下使用它,且轮换有一个统一的归属。 +- **日志变为全球可读。** 今天泄露给组织成员的粗心 secret 回显,公开后会泄露给整个互联网并在数分钟内被爬取。secret 处理纪律(不回显值/长度——已做到)的重要性大幅提升。 +- **`pull_request_target` 陷阱变为灾难性的。** 如果有人为了「修复」PR 运行而将触发器切换为 `pull_request_target`,工作流将在 base-repo 上下文中运行不可信的 fork 代码并**携带** secret——完整的密钥泄露向量。在私有仓库中这勉强无害,在公开仓库中则是灾难。e2e.yml 中触发器上的 `SECURITY —` 注释禁止此更改并指向本文。 +- **翻转时轮换密钥。** 密钥曾存在于私有仓库的 CI 中;将公开视为「假定已暴露」,在那一刻轮换 `DEEPSEEK_API_KEY_EXTERNAL`。 +- **将 secret 置于控制之下。** 确认 Settings → Actions → *"Send secrets to workflows from fork pull requests"* 保持**关闭**(这是唯一真正会打破 fork 边界的设置),并考虑将密钥移入带有 required reviewers 的 GitHub **Environment**,使即使已合并的代码也只在受控条件下使用它,且轮换有单一归属。 -以上均不需要修改工作流即可公开仓库;它们是运维步骤加上已添加的 `pull_request_target` 守卫注释。 +以上均不需要修改工作流即可公开;它们是运维步骤加上已添加的 `pull_request_target` 守卫注释。 ## 曾考虑的替代方案 -- **在 ci.yml 中添加消费 secret 的 job**:否决。它会将无密钥、可 fork、始终绿色的门禁耦合到凭证可用性和不同的触发/并发策略上;不同的生命周期,不同的文件。 -- **省略 `pull_request` 触发器**(更小的密钥暴露面):为了合并前信号而否决;安全性一节承载了被接受的暴露分析。 +- **在 ci.yml 中添加消费 secret 的 job**:否决。会将无密钥、可 fork、始终为绿的门禁耦合到凭证可用性和不同的触发/并发策略上;不同的生命周期,不同的文件。 +- **省略 `pull_request` 触发器**(更小的密钥暴露面):为获得合并前信号而否决;安全性章节承载了已接受的暴露分析。 ## 后果 -新增一个 CI 工作流和仓库首个需要维护的 secret。真实 API 套件现在成为合并门禁(受信 PR 上的合并前门禁、main 分支上的合并后门禁)并每夜运行,因此 agent 与外部 API 交互中的真实故障会在 CI 中浮现,而非仅在开发者的本地运行中出现——代价是每个受信 PR 和合并都会产生真实(但内部免费)的 API 调用。预检使 secret 配置错误变为自我通告而非静默禁用安全网。 +新增一个 CI 工作流和仓库的首个需要维护的 secret。真实 API 套件现在作为合并门禁(可信 PR 上的合并前门禁、主分支上的合并后门禁)并每夜运行,因此 agent 与外部 API 交互中的真实故障会在 CI 中浮现,而非仅在开发者的本地运行中出现——代价是每个可信 PR 和合并都会产生真实的(但内部免费的)API 调用。preflight 使 secret 配置错误变为自我通告而非静默禁用安全网。 -本设计携带一个记录在案的约束面:`pull_request` 触发器的密钥暴露权衡(去掉它以加固)、`if:` 门禁对基于作者的 Dependabot 判断的依赖,以及对 `pull_request_target` 的硬性禁止。上述公开清单是运维伴侣——本 RFC 是未来维护者在更改触发器集合或翻转仓库可见性之前应重新阅读的地方,而非从头重新推导 fork/secret 模型。 +本设计携带一个记录在案的约束面:`pull_request` 触发器的密钥暴露权衡(移除以加固)、`if:` 门禁对基于作者的 Dependabot 判断的依赖,以及对 `pull_request_target` 的硬性禁止。上述公开清单是运维伴侣——本 RFC 是未来维护者在更改触发器集合或翻转仓库可见性之前应重读的地方,而非从头重新推导 fork/secret 模型。 -定时触发器在仓库不活跃 60 天后会自动禁用(GitHub 行为);push/PR/dispatch 是后备,活跃的 monorepo 不会触及此限制。假设 runner 可出站访问 `https://api.deepseek.com`——GitHub 托管的 `ubuntu-latest` 具备此条件;出站受限的自托管 runner 需要在依赖每夜运行之前确认连通性。 +schedule 触发器在仓库不活跃 60 天后会自动禁用(GitHub 行为);push/PR/dispatch 是后备,活跃的 monorepo 不会触及此限制。假设 runner 对 `https://api.deepseek.com` 有出站连通性——GitHub 托管的 `ubuntu-latest` 具备此条件;受出站限制的自托管 runner 需要在依赖每夜运行之前确认连通性。 diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml index 592c10b7e0..6e63ef2e55 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-redundant-snapshot-log-goldens.md: badd32d4479ac6d44bb7be3cd262cba57b1b3a38 -2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: 35e4a698cbd19bcceb97df714d2b6bfb371c155a +2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: b791fbcc971eb1340f0d43ef6a60ebb29e4711f9 diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md index 35e4a698cb..b791fbcc97 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md @@ -6,32 +6,32 @@ Status: implemented ## 问题 -模型驱动的 ACP 快照场景同时包含 `session.jsonl` 和 `session.golden.jsonl`。对于普通录制场景,`session.jsonl` 是从真实运行中采集的回放 fixture(测试前置数据),回放测试将新持久化的日志归一化后与 `session.golden.jsonl` 比较。在当前 fixture 中,普通录制场景的归一化录制日志与归一化 golden 完全相同。 +模型驱动的 ACP(Agent Client Protocol)快照场景同时包含 `session.jsonl` 和 `session.golden.jsonl`。对于普通录制场景,`session.jsonl` 是从真实运行中采集的回放 fixture(测试前置数据),回放测试对新持久化的日志做归一化后与 `session.golden.jsonl` 比较。在当前 fixture 中,普通录制场景的归一化录制日志与归一化 golden 完全一致。 -手工编写的覆盖场景(`error-finish`、`cancel`)目前使用 `replay.override.json` 驱动模型行为,并保留 `session.jsonl` 作为最小占位 fixture,而 `session.golden.jsonl` 存放预期的持久化日志。覆盖文件是一个 `ReplayEntry` 对象的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }`、`{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }` 或 `{ "kind": "hang" }`。这种拆分同样没有必要:当覆盖 sidecar 存在时,`llm-replay` 会替换派生的脚本,不需要从 `session.jsonl` 获取模型分片,因此 `session.jsonl` 仍然可以充当该场景的预期会话日志产物。 +手工编写的覆盖场景(`error-finish`、`cancel`)目前使用 `replay.override.json` 驱动模型行为,并保留 `session.jsonl` 作为最小占位 fixture,而 `session.golden.jsonl` 存放预期的持久化日志。覆盖文件是一个 `ReplayEntry` 对象的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }`、`{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }` 或 `{ "kind": "hang" }`。这种拆分同样是多余的:当覆盖 sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 获取模型分片,因此 `session.jsonl` 仍可作为该场景的预期会话日志产物。 ## 决策 -彻底移除 `session.golden.jsonl` 概念。每个场景最多只有一个提交的会话日志产物 `session.jsonl`: +彻底移除 `session.golden.jsonl` 概念。每个场景最多只有一个提交到仓库的会话日志产物,即 `session.jsonl`: -- 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行的归一化持久化日志与归一化后的 `session.jsonl` 比较。 -- 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时回放适配器会忽略 fixture 中的模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。 -- 对于无模型场景,`session.jsonl` 可以保留为启动 `llm-replay` 所需的最小 fixture;除非该场景创建了持久化会话,否则无需进行会话日志比较。 +- 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行归一化后的持久化日志与归一化后的 `session.jsonl` 进行比较。 +- 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时,回放适配器不从 fixture 获取模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。 +- 对于无模型场景,`session.jsonl` 可保留为引导 `llm-replay` 所需的最小 fixture;除非场景创建了持久化会话,否则无需进行会话日志比较。 -stdout golden 保持不变;它们是面向编辑器的投影,与会话 fixture 并不冗余。 +stdout golden 保持不变;它们是面向编辑器的投影,与会话 fixture 不构成冗余。 ## 曾考虑的替代方案 -**基于共享(回放运行)上下文对两侧进行归一化**:否决。`normalizeSessionLog` 通过精确字符串匹配擦除 cwd,因此 fixture 中录制的 cwd 不会被擦除,每次比较都会失败。两侧各自基于自身 header 派生的上下文进行归一化——下方的实现说明描述了具体机制。 +**对两侧基于共享的(回放运行)上下文做归一化**:否决。`normalizeSessionLog` 通过精确字符串匹配擦除 cwd,因此 fixture 中录制的 cwd 不会被擦除,每次比较都会失败。两侧各自基于自身 header 派生的上下文做归一化——下方的实现说明描述了具体机制。 ## 验证 -`session.golden.jsonl` 不再出现在快照 harness、fixture、遗留文件守卫或文档中的任何位置;快照测试对每个模型场景都从 `session.jsonl` 派生预期会话日志;手工编写的 sidecar 场景将其预期产出的日志提交为 `session.jsonl`,并以 `replay.override.json` 作为模型行为覆盖;遗留 fixture 守卫知道每种场景类型需要哪些文件。[ACP 快照测试 RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) 描述了精简后的 fixture 集合。 +`session.golden.jsonl` 在快照 harness、fixture、遗留文件守卫和文档中均不再出现;快照测试对每个模型场景都从 `session.jsonl` 派生预期会话日志;手工编写的 sidecar 场景将预期产出的日志作为 `session.jsonl` 提交,并以 `replay.override.json` 作为模型行为覆盖;遗留 fixture 守卫知道每种场景类型需要哪些文件。[ACP 快照测试 RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) 描述了精简后的 fixture 集合。 ## 后果 -评审人失去了一个让预期持久化日志在视觉上与回放 fixture 分离的产物名称。stdout golden 仍然保护编辑器 transcript(文本记录),将回放输出与 `session.jsonl` 比较则在不重复文件的前提下保留了 agent loop(智能体循环)/持久化的回归检查。 +评审者失去了一个让预期持久化日志在视觉上与回放 fixture 分离的产物名称。stdout golden 仍保护编辑器 transcript(文本记录),将回放输出与 `session.jsonl` 比较则在不重复文件的前提下保留了循环/持久化的回归检查。 ## 实现说明 -两侧各自基于自身 header 值进行归一化,因为录制与回放具有不同的 id、路径和时间戳。`fixtureContext()` 从 fixture 的 header 派生 fixture 上下文,使已归一化的 fixture 具有幂等性。会话日志使用普通相等比较而非文件快照更新,因此比较过程永远不会改写 fixture。 +两侧各自基于自身 header 值做归一化,因为录制与回放具有不同的 id、路径和时间戳。`fixtureContext()` 从 fixture 的 header 派生上下文,使已归一化的 fixture 具有幂等性。会话日志使用普通相等比较而非文件快照更新,因此比较过程不会改写 fixture。 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index 73c068be2c..f3bddc661c 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-fork-child-replay-seed-boundary.md: a0bf064508107a23147df1a6c824c53a3906c43e -2026-06-22-fork-child-replay-seed-boundary.zh.md: 7ee6c3d373ff5e598efa82d5c8fbad6b8e162aeb +2026-06-22-fork-child-replay-seed-boundary.zh.md: 3825cce806c036c7fa21641a2f0b7cc0533d84bf diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index 7ee6c3d373..3825cce806 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -1,18 +1,18 @@ -# RFC:持久化 seed 边界以确保 fork 子会话回放路由正确 - -Status: implemented +# RFC:持久化 seed 边界以确保 fork 子会话回放正确路由 [English](2026-06-22-fork-child-replay-seed-boundary.md) | 中文 +Status: implemented + ## 问题 -[逐会话快照回放 RFC](2026-06-22-subagent-snapshot-replay.md) 让快照层表达了嵌套 agent 的结构:一个父会话加上每个进程内 subagent 各一份录制日志,每份日志以调用方会话为键独立回放为自己的脚本。该 RFC 在 §Scope 末尾提到 fork 快照是「一个简单的后续补充,不是键控方案的缺口」。这个说法对 fork 子会话而言是错的——问题不在键控,而在*脚本推导*。 +[逐会话快照回放 RFC](2026-06-22-subagent-snapshot-replay.md) 让快照层表达了嵌套 agent(智能体)的形状:一个父会话加上每个进程内 subagent 各一份已录制的日志,各自作为独立脚本回放、以调用方会话为键。该 RFC 指出(§ Scope 末尾条目)fork 快照是「一个平凡的后续补充,不是键控方案的缺口」。这对 fork 子会话而言是错的——问题不在键控,而在*脚本推导*。 -subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从录制的会话日志推导而来:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自己的模型调用。 +subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从已录制的会话日志推导:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自身的模型调用。 -**fork** 子会话不同。fork 后端用*父会话日志中一段平衡的已完成轮次前缀*([`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess))来初始化子会话,而这段 seed 会成为子会话持久化的 `log`(`Session` 构造函数将 seed 复制到 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自己的轮次。 +**fork** 子会话不同。fork 后端用*父日志的一段平衡的已完成轮次前缀*([`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess))来播种子会话,而该 seed 会成为子会话持久化的 `log`(`Session` 构造函数将 seed 复制进 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自身的轮次。 -如果从 fork 子会话的完整日志推导脚本,就会把**父会话**录制的响应当作**子会话**的模型调用来回放:活跃的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段 chunk 序列而非自己的。目前录制的场景全部是 spawn,所以这个问题从未触发——但 fork 快照会静默地路由错误,而这恰恰是快照层存在的意义所要捕获的那类 bug。 +从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段 chunk 序列而非自身的。目前已录制的场景全部是 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。 ## 决策 @@ -20,30 +20,30 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla ### 1. 会话头部的 `seedLength` -`SessionHeader` 新增可选字段 `seedLength: number`:表示前导多少个事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= seed 前缀长度);新建的 spawn 子会话不设置(等价于 0)。该字段通过 `CreateSessionOptions.meta`(以及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。 +`SessionHeader` 新增可选字段 `seedLength: number`——表示有多少前导事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= 播种前缀的长度);全新的 spawn 子会话不设置(等同于 0)。它通过 `CreateSessionOptions.meta`(及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。 -`seedLength` 是**显式**的,从不从 `seed.length` 推断。重建(resume/load)时用会话的完整存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——重建路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:重建时显式保留,而非重新默认为当前时间。) +`seedLength` 是**显式**的,绝不从 `seed.length` 推断。重建(resume/load)时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——resume 路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:重建时显式保留,而非重新默认为当前时间。) -### 2. 两个持久化后端都完整往返 +### 2. 两个持久化后端均完整往返 - **JSONL**:header 行上的 `seedLength` 字段(`toHeaderLine`/`fromHeaderLine`)。 - **SQLite**:`sessions` 表上的 `seed_length` 列。 -包含 `seed_length`、`source_event_seqs` 和 `surface_op` 的 SQLite 布局为 schema version 4。更早的 version 3 布局存在歧义,因此按预发布政策,所有非当前 `user_version` 均直接拒绝,不做迁移。 +包含 `seed_length`、`source_event_seqs` 和 `surface_op` 的 SQLite 布局为 schema version 4。更早的 version 3 布局存在歧义,因此在预发布策略下,所有非当前 `user_version` 均直接拒绝,不做迁移。 -### 3. 回放在边界之后推导子会话脚本 +### 3. 回放从边界之后推导子会话脚本 -`dsh-llm-replay` 的 `parseSessionHeader` 现在也读取 `seedLength`(缺失 ⇒ 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(seedLength)` 推导子会话的条目——即边界处及之后的事件,也就是子会话自己的模型调用。对 spawn 子会话而言 `seedLength` 为 0,这是一个空操作,因此 spawn 场景逐字节不变。 +`dsh-llm-replay` 的 `parseSessionHeader` 现在也读取 `seedLength`(缺失则为 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(seedLength)` 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 `seedLength` 为 0,此操作是空操作,spawn 场景逐字节不变。 -这关闭了路由正确性的缺口,两个录制的 fork 场景对其进行了端到端验证——见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)。 +这关闭了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)。 ## 曾考虑的替代方案 -- **在 `llm-replay` 中启发式推导边界**(seed 前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「包边界处显式优于隐式」规则跨持久化边界的应用——子会话 fixture 的读取方永远不需要重建继承在哪里结束。 -- **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」策略)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(一小组值得区分的修订),与事件词汇的 `version` 不同。新增列正是它所版本化的那种破坏性表结构变更,因此递增。 +- **在 `llm-replay` 中启发式推导边界**(播种前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头(fork 后端)持久化边界,是「在包(package)seam 处显式优于隐式」这条规则跨越持久化边界的应用——子会话 fixture(测试前置数据)的读取者永远不需要重建继承在哪里结束。 +- **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」姿态)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(一组小的、值得区分的修订),与事件词汇表的 `version` 不同。新增列正是它所版本化的那种破坏性表变更,因此需要递增。 ## 后果 -- 在 core 与两个后端之间新增一个持久化的 header 字段;核心数据结构目录(`persistence.md`)在同一个变更中更新(其 `SessionHeader` / `CreateSessionOptions` 的 `type-equiv` 块)。 +- core 与两个后端新增一个持久化 header 字段;核心数据结构目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions` 的 `type-equiv` 块)。 - 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。 -- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自己的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其 seed 前缀包含父会话的 chunk——推导出的子会话脚本必须排除它,不做 slice 时该用例为红),以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。 +- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的 chunk——推导出的子会话脚本必须排除它,不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml index 7312eb5cab..d262a105ce 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-fork-snapshot-scenarios.md: baca94d6a1071ec38ee20ca841fc3472a870b1a2 -2026-06-22-fork-snapshot-scenarios.zh.md: 227d54cc2bb2e66a391dddd29a7f2593cb02f7e2 +2026-06-22-fork-snapshot-scenarios.zh.md: b6f3f6a6f318a343d5e32573d39f11f59b509ee3 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md index 227d54cc2b..b6f3f6a6f3 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md @@ -1,31 +1,31 @@ # RFC:记录 fork 与混合 spawn+fork 快照场景 -[English](2026-06-22-fork-snapshot-scenarios.md) | 中文 - Status: implemented +[English](2026-06-22-fork-snapshot-scenarios.md) | 中文 + ## 问题 -[seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) 让 fork 子会话的回放路由正确工作了:`dsh-llm-replay` 从子会话持久化的 `seedLength` 边界处或之后的事件推导出子会话的脚本,因此 fork 子会话继承的父会话前缀不会被当作子会话自身的模型调用来回放。但该 RFC 交付时**没有录制 fork 场景**:切片逻辑仅由 `llm-replay` 的单元测试(一个合成的子会话 fixture(测试前置数据))和一个持久化往返测试覆盖。全 transcript(文本记录)快照层——那个启动真实 `acp-agent` 并回放端到端嵌套 transcript 的网——只有 spawn 子会话(`subagent-spawn`、`subagent-multi`)。一个让单元测试保持绿色的 fork 路由回归,仍然会逃过专为捕获 transcript 回归而建的那一层。 +[seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) 使 fork 子会话的回放路由正确运作:`dsh-llm-replay` 从子会话持久化的 `seedLength` 边界处或之后的事件推导出子会话的脚本,因此 fork 子会话继承的父会话前缀不会被当作子会话自身的模型调用来回放。但该 RFC 交付时**没有记录 fork 场景**——该切片仅由 `llm-replay` 的单元测试(一个合成的子会话 fixture(测试前置数据))和一个持久化往返测试覆盖。全 transcript(文本记录)快照层(即启动真实 `acp-agent` 并回放端到端嵌套 transcript 的那张网)只有 spawn 子会话(`subagent-spawn`、`subagent-multi`)。如果一个 fork 路由回归让单元测试保持绿色,它仍然会逃过专为捕获 transcript 回归而建的那一层。 -表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都通过 `cordis.yml` / `cordis.snapshot.yml` 接入为两个面向模型的工具(`subagent` → spawn、`subagent_fork` → fork),harness 收集每个子会话的日志,回放按 `seedLength` 为键转发每个子会话的 fixture。缺少的只是一个*录制好的场景*来驱动 fork 子会话走完这条路径。 +表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都在 `cordis.yml` / `cordis.snapshot.yml` 中以两个面向模型的工具接入(`subagent` → spawn、`subagent_fork` → fork),harness 会收集每个子会话的日志,回放按 `seedLength` 为键转发各子会话的 fixture。缺少的是一个**已记录的场景**来驱动 fork 子会话走完这条路径。 ## 决策 -对真实 API 录制两个场景,均在默认门禁中以 keyless 方式回放: +针对真实 API 记录两个场景,均在默认门禁中以无密钥方式回放: -- **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此能从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的录制而非手工合成。 -- **`subagent-mixed`**:父会话完成一个轮次,然后在同一个 transcript 中分别通过 `subagent`(全新的 spawn 子会话,`seedLength` 为 0)和 `subagent_fork`(fork 子会话,非零 `seedLength`)各委派一次。这是 seed-boundary 和 per-session-replay 两份 RFC 都提到的「未来补充」的混合 spawn+fork 场景:一个 transcript 同时覆盖两种传输方式和切片的两个分支(`seedLength` 0 = 无操作,`seedLength > 0` = 裁掉继承的前缀),两个子会话按 `createdAt` 排序为先 spawn 后 fork。 +- **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此可以从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的记录而非手工合成。 +- **`subagent-mixed`**:父会话完成一个轮次,然后在同一个 transcript 中分别通过 `subagent`(全新的 spawn 子会话,`seedLength` 为 0)和 `subagent_fork`(fork 子会话,`seedLength` 非零)各委派一次。这是 seed-boundary 和 per-session-replay 两份 RFC 都列为后续补充的混合 spawn+fork 场景:一个 transcript 同时覆盖两种传输方式和切片的两个分支(`seedLength` 0 = 无操作,`seedLength > 0` = 裁剪继承的前缀),两个子会话按 `createdAt` 排序为先 spawn 后 fork。 ### 为什么需要一个已完成的第一轮次 -fork 后端用父会话的**已完成轮次的平衡前缀**([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来填充子会话种子。如果父会话在第一个轮次就 fork,则没有已完成的轮次可继承,种子为空(≡ 全新 spawn,`seedLength` 为 0),这**不会**覆盖切片逻辑。因此两个场景都使用两条提示词输入:第一条提示词完成一个轮次(建立一个 codeword 供子会话稍后回忆),第二条委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带结果;真正承载验证的产物是子会话 fixture 中录制的 `seedLength`,回放切片消费的正是它。 +fork 后端用父会话的**已完成轮次的平衡前缀**([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来初始化子会话。如果父会话在第一轮次就 fork,则没有已完成的轮次可继承,seed 为空(等价于全新 spawn,`seedLength` 为 0),这**不会**覆盖切片逻辑。因此两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立一个 codeword,子会话稍后被要求回忆它),第二个 prompt 委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带产物;真正承载验证的产物是子会话 fixture 中记录的 `seedLength`,回放切片消费的正是它。 ## 后果 -- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话录制的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 -- `subagent-mixed` 是第一个在同一个 transcript 中驱动两个*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。 -- 进程外(ACP)subagent 回放是另一种形态(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。 -- 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在没有 key 时与所有录制场景一样自动跳过。 +- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话记录的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 +- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种**不同** subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。 +- 进程外(ACP)subagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。 +- 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在无密钥时自动跳过,与所有已录制场景一致。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index 9ce1aefb81..8bea2c8a5e 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-subagent-snapshot-replay.md: fbb2e5b93cced118a24f5560f229e2dc341bf3b2 -2026-06-22-subagent-snapshot-replay.zh.md: fd4ba0c109d77fdf9b64e25de74d3da577ce5a9b +2026-06-22-subagent-snapshot-replay.zh.md: 6514a0bcb5db3948f6d8f4693b17a74e4a9ad926 diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index fd4ba0c109..6514a0bcb5 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -1,58 +1,58 @@ # RFC:嵌套 agent 的逐会话快照回放 -Status: implemented - [English](2026-06-22-subagent-snapshot-replay.md) | 中文 +Status: implemented + ## 问题 -快照测试层(`pnpm run test:snapshot`)启动真实的 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放录制的会话,并将归一化后的 stdout transcript(文本记录)与重新持久化的会话日志同提交的 golden 文件做 diff。这是唯一一个端到端验证完整编辑器侧 transcript 的测试层。 +快照测试层(`pnpm run test:snapshot`)启动真实的 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放录制的会话,并将归一化后的 stdout transcript(文本记录)与重新持久化的会话日志对已提交的金标文件做 diff。这是唯一一个端到端验证完整编辑器侧 transcript 的测试层。 -它最初为**单会话单进程**而建,这一假设硬编码在两处: +该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处: -- **`dsh-llm-replay` 没有任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent 和进程内 subagent 同时在一个 context 上流式输出时,调用交错,单一游标会把子 agent 的脚本交给父 agent(反之亦然)。 -- **harness 只收割一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的**第一个** `.jsonl`。subagent 作为同一 cwd bucket 中的第二个 `Session` 运行、拥有自己的日志,因此子 agent 的 transcript 被静默丢弃。 +- **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent 和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent(反之亦然)。 +- **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行,在同一个 cwd bucket 下有自己的日志,因此子 agent 的 transcript 被静默丢弃。 -这正是 [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 中记录的 `TODO(subagent-snapshots)` 延期项:进程内后端(PR2)已有单元测试和 e2e 覆盖,但全 transcript 快照层在本基础设施落地之前无法表达嵌套 agent 的形态。本 RFC 即为该堆叠后续。 +这就是 [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 中记录的 `TODO(subagent-snapshots)` 延期项:进程内后端(PR2)已有单元测试和 e2e 覆盖,但全 transcript 快照层在本基础设施就绪之前无法表达嵌套 agent 的形态。本 RFC 即为该堆叠后续。 ## 决策 -回放按**调用方会话**键控,harness 收割**所有**会话日志。 +回放按**调用方会话**键控,harness 收集**所有**会话日志。 -### 1. 调用方会话 id 随模型请求传递 +### 1. 调用方会话 id 附着在模型请求上 -`GenerateOptions` 新增可选字段 `sessionId`,在请求组装时从 `agent.session.id` 打入。适配器忽略它;`llm/stream` 监听器用它按发起方会话路由。其类型为 `Branded<'SessionId'>`(来自 `dsh-brand`)而非 `dsh-session` 的 `SessionId`,因为后者所在包导入了 `dsh-llm` 的 `Message`,反向导入会形成循环。两个类型等价,会话 id 赋值无需强制转换。将 brand 移入专用 ids 包属于独立工作,因为它会触及所有 id 导入。 +`GenerateOptions` 新增可选字段 `sessionId`,在请求组装时从 `agent.session.id` 赋值。适配器忽略它;`llm/stream` 监听器用它按发起会话路由。其类型为 `Branded<'SessionId'>`(来自 `dsh-brand`)而非 `dsh-session` 的 `SessionId`,因为后者所在包(package)导入了 `dsh-llm` 的 `Message`,反向导入会形成循环。两个类型等价,因此会话 id 赋值无需类型转换。将 brand 移到一个专用 ids 包属于独立工作,因为它会影响所有 id 导入。 ### 2. 回放按首次调用顺序将活跃会话绑定到录制脚本 -嵌套场景录制不止一份日志:父会话(`session.jsonl`)加每个 subagent 子会话各一份(`session.1.jsonl`、……)。`dsh-llm-replay` 全部加载,为每个录制会话推导一份脚本,并按 header 中的 `createdAt` 排序(父会话先于子会话创建)。 +嵌套场景录制多份日志:父会话(`session.jsonl`)加每个 subagent 子会话各一份(`session.1.jsonl`……)。`dsh-llm-replay` 全部加载,为每个录制会话派生一份脚本,并按 header 中的 `createdAt` 排序(父会话先于子会话创建)。 -活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id,因此活跃会话无法通过 id 相等绑定脚本。取而代之的是**首次调用顺序**绑定:第一个发起模型调用的活跃会话认领排序第一的脚本(即父会话——`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。之后每个会话独立推进自己的游标。 +活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id,因此活跃会话无法通过 id 相等绑定到脚本。取而代之的是**首次调用顺序**绑定:第一个发起任何模型调用的活跃会话认领第一份有序脚本(即父会话:`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。此后每个会话独立推进自己的游标。 -这按**谁在调用**键控,而非按全局调用顺序——因此即使 subagent 将来并发运行或在后台运行也保持正确(全局游标会导致交错)。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径的行为与旧版逐字节一致。活跃会话数多于录制脚本数是一个 fail-loud 错误(出现了未录制的 subagent),绝不会静默误路由。 +这种方式按**谁在调用**键控,而非按全局调用顺序。因此即使 subagent 将来并发或在后台运行(全局游标会导致交错),它仍然正确。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径与旧行为逐字节一致。活跃会话数多于录制脚本数时会快速失败报错(出现了未录制的 subagent),绝不会静默错误路由。 -子 fixture 按 `createdAt` 排序,在兄弟会话严格顺序执行时与调用顺序一致。id 平局打破只是让退化碰撞确定化。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。 +子 fixture(测试前置数据)按 `createdAt` 排序,在兄弟会话严格顺序执行时与调用顺序一致。id 平局打破仅使退化碰撞具有确定性。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。 ## 曾考虑的替代方案 -曾考虑并否决的方案是将父子日志**按调用顺序合并**为一份全局脚本(仅在进程内 subagent 严格嵌套执行——父 agent 阻塞等待子 agent——时才正确)。对当前的同步切面更简单,但把「父阻塞于子」这一不变式烤死了;未来的后台/并发 subagent 会打破它,而逐会话键控不会。 +曾考虑但否决的方案是:**将父子日志按调用顺序合并**为一份全局脚本(仅在进程内 subagent 执行严格嵌套——父 agent 阻塞等待子 agent——时才正确)。对当前的同步裁剪而言更简单,但将「父阻塞于子」这一不变式固化了进去;未来若引入后台/并发 subagent 就会失效。逐会话键控则不会。 -### 3. harness 收割所有日志,主会话优先 +### 3. harness 收集所有日志,主会话优先 -`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`(JSONL 后端将父会话与同 cwd 的子会话放在同一 bucket),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回 fixture(`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收割的日志与对应 fixture 做 diff。归一化器已接受复数会话 id 并折叠任何游离 UUID,因此无需修改归一化器。 +`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`(JSONL 后端将父会话与同 cwd 的子会话放在同一个 bucket),解析各自的 header,并按主会话优先排序:顶层会话(无 `parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果;spec 在录制时将每份日志写回对应 fixture(`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID,因此无需修改归一化器。 ### 4. 场景 新增两个嵌套场景,均对真实 API 录制: -- **`subagent-spawn`**:父 agent 通过 `subagent` 工具将一个子任务委派给一个新 spawn 子会话(2 个会话)。 -- **`subagent-multi`**:父 agent 委派两个子任务,各自交给独立的 spawn 子会话(3 个会话),以三份并行脚本和同一父会话下两个子会话的 `createdAt` 排序来压测逐会话键控。 +- **`subagent-spawn`**:父 agent 通过 `subagent` 工具将一个子任务委派给一个新 spawn 的子 agent(2 个会话)。 +- **`subagent-multi`**:父 agent 委派两个子任务,各自交给自己的 spawn 子 agent(3 个会话),以三份并行脚本和同一父 agent 下两个子会话的 `createdAt` 排序来压测逐会话键控。 两者均在默认门禁中以 keyless 方式回放。 ## 后果 -- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent transcript 现在是快照的一等形态。 -- `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外也有用(遥测、请求路由)。 -- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子会话都是 spawn(全新)。键控按会话路由而非按后端路由,因此对 fork 也已正确。但脚本*推导*并非如此:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,从整份日志推导脚本会把父会话的响应当作子会话的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。 -- 进程外(ACP)subagent 是完全不同的回放形态(每个子 agent 是独立进程、有自己的 replay),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 +- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。 +- `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外同样有用(遥测、请求路由)。 +- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本**派生**逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。 +- 进程外(ACP)subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml index 10cfb7320c..a0daca4800 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-hook-snapshot-matrix.md: 8505e82fb681975c7506102a3eb858a29ccc11c8 -2026-07-04-hook-snapshot-matrix.zh.md: 6bd4b65b6ba2e16f8433fb0e67ae7ca4eb6a470e +2026-07-04-hook-snapshot-matrix.zh.md: 9a9f085400e938bc15c171f652d65f7bcdfa518f diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md index 6bd4b65b6b..9a9f085400 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md @@ -1,4 +1,4 @@ -# RFC:钩子快照矩阵——覆盖两种桥接的端到端金标测试 +# RFC:Hook 快照矩阵——覆盖两种 bridge 的端到端 golden 测试 Status: implemented @@ -6,45 +6,45 @@ Status: implemented ## 问题 -钩子桥接——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code 钩子点)与 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 钩子点)——将外部钩子命令映射到 harness 的拦截 seam 上。它们拥有深度的单元测试与覆盖率规格覆盖(每个决策分支、每种 payload 方言,均对 mock seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但全 transcript(文本记录)快照层——那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将归一化的 ACP stdout 与重新持久化的日志对比已提交金标的网——只覆盖了**一个**钩子:Claude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。 +hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——将外部 hook 命令映射到 harness 的拦截 seam 上。它们拥有深度的单元测试和 coverage-spec 覆盖率(每个决策分支、每种 payload 方言,均对 mock 的 seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但完整 transcript(文本记录)快照层:那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将规范化的 ACP stdout 与重新持久化的日志与已提交 golden 做 diff 的网,只覆盖了**一个** hook:Claude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。 -这正是 mock 单元测试在结构上无法替代的层级:它让真实的桥接翻译真实钩子进程的结果,送入真实的 seam 决策,再由真实的 agent loop(智能体循环)做出反应,渲染结果与编辑器所见完全一致。一个桥接翻译或循环结构的回归,即使让所有单元测试保持绿色,也会在除那一个钩子点之外的所有点上逃逸——而对于 Codex 桥接,ACP 示例甚至没有加载它,因此没有任何 Codex 钩子能端到端触发。 +这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实 hook 进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个 hook 点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex hook 能端到端触发。 ## 决策 实现由两个耦合部分组成: -### 1. ACP 示例同时加载两种钩子桥接 +### 1. ACP 示例同时加载两种 hook bridge -`examples/acp-agent/cordis.yml` 与 `cordis.snapshot.yml` 现在在 `dsh-hooks-claude` 之外同时加载 `dsh-hooks-codex`,各自指向自己的配置文件(Claude 用 `./hooks.json`,Codex 用 `./codex-hooks.json`——两种方言无法共用一个文件)。这是一个真正的产品表面变更,而非仅测试用的接线:交付的 ACP 服务器(以及 `demo:acp` 入口)现在同时携带两种桥接。 +`examples/acp-agent/cordis.yml` 和 `cordis.snapshot.yml` 现在同时加载 `dsh-hooks-codex` 与 `dsh-hooks-claude`,各自指向自己的配置文件(Claude 用 `./hooks.json`,Codex 用 `./codex-hooks.json`——两种方言无法共用一个文件)。这是一个真正的产品接口变更,而非仅用于测试的接线:交付的 ACP 服务器(以及 `demo:acp` 入口)现在同时携带两种 bridge。 -这是安全的,因为配置文件不存在时桥接是**静默空操作**:`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不挂载 stdout logger,因此该警告不会到达 ACP JSON-RPC 通道。只需要 Claude 钩子的场景(或真实项目)只提供 `hooks.json`;Codex 桥接找不到 `codex-hooks.json` 便自行消失。这已通过实验验证:两种桥接同时加载时,所有既有快照(均未提供 `codex-hooks.json`)逐字节一致。 +这是安全的,因为配置文件不存在时 bridge 是**静默无操作**的:`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不附带 stdout logger,因此警告不会到达 ACP JSON-RPC 通道。只需要 Claude hook 的场景(或真实项目)只提供 `hooks.json`;Codex bridge 找不到 `codex-hooks.json` 便自动消失。这已通过实验验证:在两种 bridge 同时加载的情况下,所有既有快照(均不附带 `codex-hooks.json`)逐字节一致。 -同时加载是让快照层能够在产品交付的同一个真实应用上对每种方言进行测试的最低要求。录制(启动 `cordis.yml`)天然加载两者,回放以同样方式继承:`cordis.snapshot.yml` 是 `cordis.yml` 的 include-overlay,仅替换 llm 条目(见 [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)),因此添加到运行时配置树的桥接无需第二次编辑即出现在回放树中。 +同时加载是让快照层能够在产品交付的同一个真实应用上验证每种方言的最低要求。录制(启动 `cordis.yml`)天然加载两者,回放以同样方式继承:`cordis.snapshot.yml` 是 `cordis.yml` 的 include-overlay,只替换 llm 入口(见[单一来源 acp-agent 回放配置](2026-07-04-single-source-acp-replay-config.md)),因此添加到运行时树的 bridge 无需第二次编辑即出现在回放树中。 -### 2. 每个钩子点 × 其标志性结果各一个快照场景,覆盖两种方言 +### 2. 每个 hook 点 × 其主要结果各一个快照场景,覆盖两种方言 `examples/acp-agent/tests/snapshots/` 下共 13 个场景,命名为 `hook-<dialect>-<point>-<outcome>`: - **手工编写、无模型轮次**(无密钥、无 sidecar——派生的回放脚本为空;比对的是携带 `hook/*` 事件的 `rejected` 轮次):`hook-cc-promptsubmit-block`、`hook-codex-promptsubmit-block`。 -- **对真实 API 录制、录制期间钩子活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`(allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`(deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`(block 并附反馈)、`hook-{cc,codex}-posttool-context`(accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞式 Stop 钩子通过 steering(中途引导)强制多走一步)。 +- **对真实 API 录制、录制期间 hook 活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`(allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`(deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`(block 并附带反馈)、`hook-{cc,codex}-posttool-context`(accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞性 Stop hook 通过 steering(中途引导)强制多走一步)。 -每个钩子命令只输出**固定字面字符串**(无时间戳/pid/`$RANDOM`/cwd 回显);快照归一化器擦除 `hook/result` 携带的唯一易变字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是桥接的一个 `TODO`,因此无条件的 Stop 钩子会对每一步都 force-continue。 +每个 hook 命令只输出**固定字面量字符串**(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop hook 会在每一步都 force-continue。 -### 三个钩子点被有意排除在快照之外 +### 三个 hook 点被有意排除在快照之外 -在构建矩阵过程中发现,记录在此是因为这是一个决策而非疏漏: +在构建矩阵过程中发现,记录于此是因为这些遗漏是决策而非疏忽: -- **`SessionStart` 与 `SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,**没有轮次绑定**。产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的金标甚至在自身回放时都无法复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在桥接的单元覆盖中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——这些点就可以纳入快照。) -- **`SubagentStop`** 是纯观察:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript **什么都不写**,因此金标会与无钩子运行逐字节一致,永远无法被证明失败——一道咬不到人的守卫。它留在单元覆盖中(`bridge.spec.ts` 已断言该纯观察调用)。 +- **`SessionStart` 与 `SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,**没有**轮次绑定。由此产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的 golden 甚至无法在自身回放中复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在 bridge 的单元覆盖率中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——它们就可以纳入快照。) +- **`SubagentStop`** 是纯观察性的:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript **不写入任何内容**,因此 golden 与无 hook 运行逐字节一致,永远无法被证明失败——一道永远不会触发的守卫。它留在单元覆盖率中(`bridge.spec.ts` 已断言了纯观察调用)。 -因此该矩阵覆盖了所有具有**确定性、可观测 transcript 足迹**的钩子点,涵盖两种方言。 +因此,该矩阵覆盖了所有具有**确定性、可观测** transcript 足迹的 hook 点,涵盖两种方言。 ## 后果 -- 每个具有可观测 transcript 的桥接 seam 映射现在都在全 transcript 层、在真实应用中、为两种方言设有守卫——包括此前完全没有端到端覆盖的 Codex 桥接。录制的金标捕获了模型对 denied/blocked/force-continued 轮次的真实反应,这是手工编写的 transcript 只能猜测的。 -- block 场景无需密钥(无模型轮次);其余场景从录制的 fixture(测试前置数据)无密钥回放。`pnpm run test:snapshot:record` 从真实 API 重新生成录制的 fixture,无密钥时像所有录制场景一样自动跳过。 -- prove-red 纪律成立:篡改钩子配置的输出(例如修改 deny 原因)会使其场景在回放时变红——钩子进程在回放期间**真实运行**(只有模型被回放),因此金标守卫的是实际的 hook→seam→loop 路径,而非它的 mock。 -- `acp-agent` 演示现在加载了一个通常会空操作的 Codex 桥接(典型项目中没有 `codex-hooks.json`),这正是预期的 fail-soft 行为,而非代价。 +- 每个具有可观测 transcript 的 bridge seam 映射现在都在完整 transcript 层级、在真实应用中、对两种方言受到守护——包括此前完全没有端到端覆盖率的 Codex bridge。录制的 golden 捕获了模型对 deny/block/force-continue 轮次的真实反应,这是手工编写的 transcript 只能猜测的。 +- block 场景无需密钥(无模型轮次);其余场景从录制的 fixture(测试前置数据)无密钥回放。`pnpm run test:snapshot:record` 从真实 API 重新生成录制的 fixture,无密钥时自动跳过,与所有录制场景一致。 +- prove-red 纪律成立:篡改 hook 配置的输出(例如修改 deny 原因)会使其场景在回放时变红——hook 进程在回放期间**真实运行**(只有模型被回放),因此 golden 守护的是实际的 hook→seam→loop 路径,而非它的 mock。 +- `acp-agent` 演示现在加载了一个通常会无操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml index 642ba7f9ad..4aa9340c22 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-single-source-acp-replay-config.md: 922bdcced50f8e289449e05b51774f202228b0f8 -2026-07-04-single-source-acp-replay-config.zh.md: d27ea0d3b7227f0fb5f349478591962dd5fe8030 +2026-07-04-single-source-acp-replay-config.zh.md: b347186f362fa5454f7bd5106c2e261cb8a00b61 diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md index d27ea0d3b7..b347186f36 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md @@ -1,27 +1,27 @@ -# RFC:将 acp-agent 回放配置收归单一来源 - -Status: implemented +# RFC:将 acp-agent 回放配置改为单一来源 [English](2026-07-04-single-source-acp-replay-config.md) | 中文 +Status: implemented + ## 问题 -`examples/acp-agent` 曾维护两份手写配置:`cordis.yml`(线上树)和一份 `cordis.snapshot.yml`,后者逐条镜像前者、仅替换 LLM 后端。去掉注释后,全部差异只是八行 `llm-deepseek` 段落换成两行 `llm-replay` 段落。每次应用形态变更都要改两遍,且没有门禁保证对称性:如果两份副本漂移,快照层会静默地测试一个与实际交付不同的应用——正是快照层本身要消除的[「单元全绿、产品却坏」类缺口](../../../postmortem/0001-acp-default-export-drops-inject.md),在更高一层被重新引入,唯一的防线是评审者的警觉。 +`examples/acp-agent` 曾维护两份手写配置:`cordis.yml`(正式运行树)和 `cordis.snapshot.yml`(逐条镜像前者,仅替换 LLM(大语言模型)后端)。去掉注释后,全部差异只是八行的 `llm-deepseek` 段落换成两行的 `llm-replay` 段落。每次应用结构变更都要改两遍,且没有门禁保障对称性:一旦两份副本漂移,快照层就会悄悄测试一个与实际交付不同的应用——正是快照层本要消除的["单元测试全绿、产品却坏了"这类缺口](../../../postmortem/0001-acp-default-export-drops-inject.md),在上一层被重新引入,唯一的防线是评审者的警觉。 ## 决策 -`cordis.snapshot.yml` include 线上配置,按 id 和 name 禁用指定的 DeepSeek 适配器,并插入回放适配器。因此除此之外的所有条目均来自交付树。回放时选择 overlay;录制仍然启动 `cordis.yml`,加载守卫允许被有意禁用的条目。 +`cordis.snapshot.yml` include 正式配置,通过 id 和 name 禁用指定的 DeepSeek 适配器,并插入回放适配器。其余所有条目因此来自正式运行树。回放时选择 overlay;录制仍然启动 `cordis.yml`,加载守卫允许被有意禁用的条目。 -overlay 依赖的一个 vendor 插件事实(有意为之):include 在加载文件时应用 `patches`——其 `refresh()`/`internal/update` 路径重读时不重新打补丁——这恰好满足一次性回放启动的需要(回放应用不加载 `hmr`,也没有东西在运行中改写配置)。快照套件即为证明:所有场景在 overlay 上原样通过,包括逐字节一致的 golden 文件。 +overlay 依赖一个 vendor 插件的事实,这是有意为之:include 在加载文件时应用 `patches`,其 `refresh()`/`internal/update` 路径重读时不会重新打补丁。这恰好满足一次性回放启动的需要(回放应用不加载 `hmr`,也没有东西在运行中改写配置)。快照套件即为证明:所有场景在 overlay 上原样通过,包括逐字节一致的 golden 文件。 ## 曾考虑的替代方案 -### 为什么不选这些方案? +### 为何不采用这些替代方案? -保留完整的双份配置并加一道对称性校验门禁是记录在案的兜底方案——它能消除静默漂移这一类问题,但仍保留一份 125 行的近似副本,其全部内容只是一个条目的差异,且随应用每增加一个插件而增长。在 bin 侧做替换(解析配置、替换条目、删除文件)会把 YAML 手术放进发布产物,并将回放差异移出视野;overlay 方案让差异保持声明式、可读、且紧邻基础配置——这正是双份配置的支持者真正想要的教学价值。 +保留完整的双副本并加一道对称性校验门禁是记录在案的退路——它能消除静默漂移这一类问题,但仍保留一份 125 行的近乎复制品,其全部内容只是一个条目的差异,且随应用每增加一个插件而增长。在 bin 侧做替换(解析配置、替换条目、删除文件)则会把 YAML 手术放进发布产物,并把回放差异藏到视线之外;overlay 让差异保持声明式、可读,且紧邻基础配置——这正是双副本支持者真正看重的教学价值。 ## 后果 -- 向 `cordis.yml` 添加的插件无需第二次编辑即进入回放树;漂移类问题从结构上消除,而非仅靠门禁拦截。 -- overlay 依赖条目携带稳定的 `id:`。禁用补丁上的 `name` 断言防止误定位(id 被复用时补丁跳过而非禁用错误的插件)。id **重命名**会使补丁退化为跳过,其警告需要一个回放应用有意不具备的 logger——可观察的结果是一条无用的无密钥 `llm-deepseek` 条目与 `llm-replay` 并存,回放输出仍然正确(`llm-replay` 拥有流的短路权);这属于留给评审发现的配置腐烂,而非错误的快照。顶层插入的条目若 id 与已有条目冲突,通过 loader 的 id map 以 last-wins 解析——当前配置无冲突,新增补丁行才是引入冲突的位置。 -- 如果未来回放树需要第二处分歧(另一个后端被替换),只需多加一行补丁,而非再 fork 一份文件。 +- 向 `cordis.yml` 添加插件即自动进入回放树,无需第二次编辑;漂移这一类问题从结构上消失,而非靠门禁拦截。 +- overlay 依赖条目携带稳定的 `id:`。禁用补丁上的 `name` 断言防止误定位(id 被复用时补丁跳过而非禁用错误的插件)。如果 id 被**重命名**,补丁退化为跳过,其警告需要一个回放应用有意不具备的 logger——可观测结果是一条无效的无密钥 `llm-deepseek` 条目与 `llm-replay` 并存,回放输出仍然正确(`llm-replay` 拥有流的短路权);这属于配置腐烂,留给评审发现,不会产生错误的快照。顶层插入一个 id 与既有条目冲突的新条目时,loader 的 id map 以后者为准;当前配置无冲突,新增补丁行才是引入冲突的场所。 +- 如果未来回放树需要第二处差异(另一个后端被替换),只需多加一行补丁,而非再 fork 一份文件。 diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml index 3ee0a6df6d..3b2d92e28c 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-pin-request-header-content-in-one-scenario.md: 5ccaa23a268114c5ba37ec153f4960b47df13bfd -2026-07-06-pin-request-header-content-in-one-scenario.zh.md: f5ef5e056bb2c05d14ed71315f31c962237db72c +2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 909968430dc3e648e09eeeedd47436c35b90b870 diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md index f5ef5e056b..909968430d 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md @@ -1,35 +1,35 @@ -# RFC:在单一快照场景中固定 request-header 内容 - -[English](2026-07-06-pin-request-header-content-in-one-scenario.md) | 中文 +# RFC:在单个快照场景中固定请求头内容 Status: implemented +[English](2026-07-06-pin-request-header-content-in-one-scenario.md) | 中文 + ## 问题 -ACP(Agent Client Protocol)快照测试套件需要证明每个 `request/header` 中实际发送的组合系统提示词和工具 schema 列表,但如果在每个 `session.jsonl` 中重复这些内容,一次提示词或 schema 编辑就会改写数十条巨大的单行 JSON 记录。保留一份原始 header 可以避免重复,但提示词的评审体验仍然很差:行文被 JSON 转义到一行里,与数千字符的工具 schema 混在一起。 +一个 ACP(Agent Client Protocol)快照测试套件需要证明每个 `request/header` 中实际发送的组合系统提示词与工具 schema 列表,但如果在每个 `session.jsonl` 中重复这些内容,一次提示词或 schema 编辑就会改写数十条巨大的单行 JSON 记录。保留一份原始 header 可以避免重复,但提示词的评审体验仍然很差:行文被 JSON 转义到一行中,与数千字符的工具 schema 混在一起。 ## 决策 -每个 header 组合类别恰好有一个场景被标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.golden.md` 以普通 Markdown 存放归一化后的组合提示词,`tool-schemas.golden.json` 以结构化 JSON 存放完整的初始 schema 及后续 schema 变更,`session.jsonl` 保留 config、reason 和任何模型可见的前缀,同时将 `header.system` 和 `header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其余所有 JSONL 使用相同的提示词和工具 token,并同样对 session-prefix 内容做 token 化。固定机制实现在 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) 中,其套件工厂强制每个类别只有一个 pin。 +每个 header 组合类别恰好有一个场景被标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.golden.md` 以普通 Markdown 存放归一化后的组合提示词,`tool-schemas.golden.json` 以结构化 JSON 存放完整的初始 schema 及后续 schema 变更,而 `session.jsonl` 保留 config、reason 及任何模型可见的前缀,同时将 `header.system` 和 `header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其余所有 JSONL 使用相同的提示词和工具 token,并同样对会话前缀内容做 token 化处理。固定机制实现在 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) 中,其套件工厂强制每个类别只有一个固定场景。 -纯净的 `scrubSystemPrompts` 和 `scrubToolSchemas` 归一化器应用于所有存储的 session fixture(测试前置数据),独立地对初始 header 内容和 header-delta 批量内容做 token 化。`scrubRequestHeaders` 还为非固定场景对 session-prefix 内容做 token 化,同时保留结构性事实:system-delta 的位置与数量、增删改的工具名称、前缀消息数量、字段存在性、config 和 reason。record 和 refresh 的回写操作在写入 JSONL 前应用相应的 scrub,并从归一化的实时 header 和 delta 重新生成两个 sidecar 文件,因此两条路径都不会将提示词/schema 批量内容重新引入 JSONL,也不会让评审产物变陈旧。 +纯粹的 `scrubSystemPrompts` 和 `scrubToolSchemas` 归一化器应用于每个存储的会话 fixture(测试前置数据),独立地对初始 header 内容和 header-delta 批量内容做 token 化。`scrubRequestHeaders` 还为非固定场景的会话前缀内容做 token 化,同时保留结构性事实:system-delta 的位置与数量、新增/移除/变更的工具名称、前缀消息数量、字段存在性、config 和 reason。record 与 refresh 的回写操作在写入 JSONL 前应用相应的 scrub,并从归一化后的实时 header 和 delta 重新生成两个 sidecar 文件,因此两条路径都不会把提示词/schema 批量内容重新引入 JSONL,也不会让评审产物变陈旧。 -守卫使这一拆分自我强制。在磁盘上:每个 `session*.jsonl` 都是提示词和 schema 两个 scrubber 的不动点;只有非固定 fixture(测试前置数据)还必须是完整 header scrub 的不动点;两个 sidecar 恰好存在于固定 fixture 旁边,采用规范的换行终止格式;每个类别有且仅有一个 pin。在运行时:由 parent、spawn 子进程、fork 子进程、初始请求或恢复产生的每个 `request/header`,在易变值归一化后必须与重建的 pin 匹配;固定运行的提示词和 schema delta 也必须与其 sidecar 匹配。如果 header 缺少字符串类型的 prompt、缺少数组类型的工具列表,或出现未声明的 `request/header-delta`,则立即报错。 +守卫机制使这一拆分自我强制。在磁盘上:每个 `session*.jsonl` 都是提示词和 schema 两个 scrubber 的不动点;只有非固定 fixture 还必须是完整 header scrub 的不动点;两个 sidecar 文件恰好存在于固定 fixture 旁边,采用规范的换行终止格式;每个类别有且仅有一个固定场景。在运行时:由 parent、spawn 子会话、fork 子会话、初始请求或 resume 产生的每个 `request/header`,在经过易变值归一化后必须与重建的固定内容匹配;固定运行的提示词和 schema delta 也必须与其 sidecar 匹配。如果 header 没有字符串类型的 prompt、没有数组类型的工具列表,或包含未声明的 `request/header-delta`,则立即失败并报错。 -一个 pin 覆盖整个套件,因为每个会话(parent、spawn 子进程、fork 子进程)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合在设计上变为会话相关的(例如受限的 subagent 工具集),则分化出的形状获得自己的固定场景。 +一个固定场景覆盖整个套件,因为每个会话(parent、spawn 子会话、fork 子会话)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合将来在设计上变为会话相关的(例如受限的 subagent 工具集),那么分歧的形态将获得自己的固定场景。 ## 曾考虑的替代方案 - **每次变更重新录制或手动编辑所有 fixture**:保留了精确的 header,但行为差异被重复的提示词和 schema 内容淹没。 -- **仅在比较时 scrub,fixture 保持原始状态**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时整体改写。存储 token 诚实地表明每个 JSONL 没有固定什么。 -- **全部 scrub,不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema)的唯一端到端记录。生成的工具目录只孤立地记录每个工具;只有真实 fixture 能固定组合后的完整集合。 -- **将完整的 pin 全部保留在 JSONL 中**:消除了套件级重复,但提示词和 schema 变更仍然表现为一行转义文本。Markdown 和结构化 JSON 为各自的内容提供了自然的评审格式,同时不削弱重建 header 的断言。 -- **精简会话日志本身(记录内容摘要,header 存到别处)**:违反可重建契约:产品日志必须逐比特重现每个请求(见[可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md))。header 体积是测试产物的问题,在测试归一化中解决;线上日志不受影响。 +- **仅在比较时 scrub,fixture 保持原始内容**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时会整体重写。存储 token 诚实地表明每个 JSONL 没有固定什么。 +- **全部 scrub,不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema)的唯一端到端记录。生成的工具目录只孤立地记录每个工具;只有真实 fixture 才能固定组合后的完整集合。 +- **将完整固定内容全部保留在 JSONL 中**:消除了套件范围的重复,但提示词和 schema 变更仍然是一行转义文本。Markdown 和结构化 JSON 为每种内容提供其自然的评审格式,同时不削弱重建 header 的断言。 +- **精简会话日志本身(记录内容摘要,将 header 存放在别处)**:违反可重建性契约:产品日志必须逐位重现每个请求([可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md))。header 体积是测试产物的问题,在测试归一化中解决;线上日志不受影响。 ## 验证 -套件针对拆分后的 pin 回放每个场景。单元覆盖率检验独立 scrubber 和完整 scrubber、两种 sidecar 格式、record/refresh 重新生成、归一化的提示词/schema 提取、不动点强制、必需文件对称性、重建 header 的一致性,以及 delta 拒绝。 +套件针对拆分后的固定内容回放每个场景。单元测试覆盖率涵盖独立 scrubber 和完整 scrubber、两种 sidecar 格式、record/refresh 重新生成、归一化提示词/schema 提取、不动点强制、必需文件对称性、重建 header 一致性以及 delta 拒绝。 ## 后果 -系统提示词的变更在每个受影响的组合类别中产生一个面向行的 Markdown diff;工具描述的变更在每个类别中产生一个结构化 JSON diff;普通的行为 fixture 不受影响。session fixture 以 token 显示被省略的内容,运行时一致性守卫使每个拆分 pin 对其类别中的所有会话具有权威性。每个固定场景携带两个生成的、换行规范化的 sidecar 文件。 +系统提示词变更在每个受影响的组合类别中产生一个面向行的 Markdown diff;工具描述变更在每个类别中产生一个结构化 JSON diff;普通行为 fixture 不受影响。会话 fixture 对省略的内容显示 token,运行时一致性守卫使每个拆分固定场景对其类别内的所有会话具有权威性。每个固定场景携带两个生成的、换行规范化的 sidecar 文件。 diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml index d888da4d05..c582f34c23 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-shared-acp-snapshot-package.md: 81714191a704af1a9ed029fb8004deac88e39427 -2026-07-08-shared-acp-snapshot-package.zh.md: 2a7c3091c731ded61bed939c8ae0a323123daac9 +2026-07-08-shared-acp-snapshot-package.zh.md: f80c8e49e80e9bf287c84c0ff4fb00377fad5175 diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md index 2a7c3091c7..f80c8e49e8 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -1,38 +1,38 @@ -# RFC:将 ACP 快照测试套件提取为支持包 - -Status: implemented +# RFC:将 ACP 快照套件提取为支持包 [English](2026-07-08-shared-acp-snapshot-package.md) | 中文 +Status: implemented + ## 问题 -ACP 快照层([快照 RFC](2026-06-19-acp-snapshot-tests.md))由三个位于某个示例测试目录内的模块构成:`snapshot-harness.ts`(启动真实 bin 子进程、通过 ACP JSON-RPC 驱动它、收集持久化日志)、`snapshot-normalize.ts`(纯粹的 golden 归一化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(record/replay 模式、stdout-golden 与日志比对、pinned-header 一致性守卫、orphan/required-file/single-pin 元测试)。 +ACP 快照层([快照 RFC](2026-06-19-acp-snapshot-tests.md))由位于某个示例测试目录中的三个模块构成:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,收集持久化日志)、`snapshot-normalize.ts`(纯粹的 golden 规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体加 fixture(测试前置数据)守卫(record/replay 模式、stdout-golden 与日志比对、pinned-header 一致性守卫、orphan/required-file/single-pin 元测试)。 -第二个 ACP 示例只能复制 record、归一化与收集逻辑,而这些逻辑必须保持一致。`examples/` 下的代码还处于包(package)覆盖率门禁之外,且原有 harness 只能取消权限请求。共享包使这些机制纳入度量,并允许场景脚本化地指定审批答案。 +第二个 ACP 示例只能复制 record、规范化和收集逻辑,而这些逻辑必须保持一致。`examples/` 下的代码也不在包(package)覆盖率门禁范围内,且原始 harness 只能取消权限请求。共享包使这些机制纳入度量,并允许场景脚本化地提供审批答案。 ## 决策 -机制代码位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)(`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,配合自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` 覆盖层([单源 replay 配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在该边界——库接收的是已解析的 `mode`。 +这些机制位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)(`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,依赖自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` overlay([单源 replay 配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在边缘层——库接收的是已解析的 `mode`。 -**`src/harness.ts`** 提供 `runScenario` 及其脚本/结果类型,以 agent 的 bin 路径和配置路径为参数。权限答案构成一个 FIFO 队列,按稳定的 option kind(而非随机的 option id)索引。缺少答案时取消该请求;不可用的 kind 取消 agent 请求并使场景失败。 +**`src/harness.ts`** 提供 `runScenario` 及其脚本/结果类型,以 agent 的 bin 和配置路径为参数。权限答案构成一个 FIFO 队列,以稳定的 option kind(而非随机的 option id)为键。缺少答案时取消该请求;不可用的 kind 取消 agent 请求并使场景失败。 -**`src/normalize.ts`**:纯归一化器,按策略不含钩子。当未来的事件携带新的易变字段(如审批耗时),共享归一化器在同一个变更中学会它,保持「归一化」的含义只有一个归属地,而非各套件各自扩展清洗逻辑。 +**`src/normalize.ts`** 是纯规范化器,按策略不含钩子:当未来某个事件携带新的易变字段(例如审批耗时),共享规范化器在同一个变更中学会它,保持「规范化」的含义只有一个归属,而非各套件各自扩展清洗逻辑。 -**`src/suite.ts`**:`Scenario` 类型与 `defineAcpSnapshotSuite(options)`,注册逐场景比对、record/refresh 的 fixture 回写、header pin 及其实时一致性守卫,以及 fixture 守卫块(无 orphan 场景目录、必需文件齐全、每个 class 恰好一个 pin、每个 JSONL 是 `scrubSystemPrompts` 的不动点、非 pinning 的 fixture 也是 `scrubRequestHeaders` 的不动点)。pinned-header 契约([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每个 header class 恰好标记一个 `pinsHeader` 场景,其 `system-prompt.golden.md` 与 JSONL 工具列表将组合后的 header 拆分为可评审的产物;一致性守卫将二者与该 class 中每个实时 header 进行比对。纯辅助函数(`childFixturePaths`、`fixtureContext`、`normalizedHeaders`、`normalizedSystemPrompts`、`formatSystemPromptSnapshot`、`headerDeltaCount`)从模块导出,以便直接进行单元覆盖。 +**`src/suite.ts`** 提供 `Scenario` 类型与 `defineAcpSnapshotSuite(options)`,注册逐场景比对、record/refresh 的 fixture 回写、header pin 及其实时一致性守卫,以及 fixture 守卫块(无 orphan 场景目录、必需文件齐全、每个 class 恰好一个 pin、每个 JSONL 是 `scrubSystemPrompts` 的不动点、非 pinning fixture 也是 `scrubRequestHeaders` 的不动点)。pinned-header 契约([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件划分:每个 header class 恰好标记一个 `pinsHeader` 场景,其 `system-prompt.golden.md` 与 JSONL 工具列表将组合后的 header 拆分为可评审的产物;一致性守卫将二者与该 class 中每个实时 header 进行比对。纯辅助函数(`childFixturePaths`、`fixtureContext`、`normalizedHeaders`、`normalizedSystemPrompts`、`formatSystemPromptSnapshot`、`headerDeltaCount`)从模块导出,以便直接进行单元覆盖。 ## 曾考虑的替代方案 -- **将模块复制到每个示例中**:正是本 RFC 要阻止的分叉。record/guard 逻辑恰恰是必须在各套件间逐字节一致的代码,而 examples 在覆盖率门禁之外,因此每份副本也无法被度量。 -- **在 `examples/` 下建共享模块目录**:代码仍在覆盖率门禁之外,且需要跨示例边界的相对导入,违背包名导入约定;`examples/` 的叶子节点按设计保持精简。 -- **在 `dsh-acp-demo` 中导出 `/testing` 子路径**:将测试基础设施耦合到产品包的公开接口与依赖集中;`packages/support/` 正是为真实但兼容性要求较低的开发/测试包而设,`dsh-llm-replay` 是先例,本包是其补全。 -- **导出原始测试体函数而非套件工厂**:每个示例将重新拥有 `describe`/`it` 骨架(每套件约 80 行注册样板),却无灵活性收益;工厂让消费方只需一张场景表加一次调用,导出的纯辅助函数在工厂设计内保留了单元可测性。 -- **可注入的 ACP `Client` 工厂取代声明式 `permissionAnswers`**:灵活性最大化,但将 SDK 客户端构造泄漏给每个消费方,并在正被统一的层面重新引入逐示例漂移;声明式队列让 `input.json` 保持为唯一的脚本化接口,且可被 golden 归一化。 -- **泛化到 ACP 之外(传输无关的快照 harness)**:不存在第二种传输;harness 端到端都是 ACP 形态(SDK 客户端、JSON-RPC 帧、`session/update` 等待器),推测性的抽象会在没有消费方之前就拆出一个 seam。 +- **将模块复制到每个示例中**:正是本 RFC 要防止的 fork。record/守卫逻辑恰恰是必须在各套件间保持逐字节一致的代码,而示例不在覆盖率门禁范围内,因此每份副本也无法被度量。 +- **在 `examples/` 下建共享模块目录**:代码仍在覆盖率门禁之外,且需要跨示例边界的相对导入,违反包名导入约定;`examples/` 的叶子节点按设计应保持轻薄。 +- **`dsh-acp-demo` 的 `/testing` 子路径导出**:将测试基础设施耦合到产品包的对外服务接口与依赖集中;`packages/support/` 的存在正是为了真实但兼容性承诺较低的开发/测试包,`dsh-llm-replay` 是先例,本包与之配套。 +- **导出原始测试体函数而非套件工厂**:每个示例将重新拥有 `describe`/`it` 骨架(每套件约 80 行注册样板),却无灵活性收益;工厂使消费方只需一张场景表加一次调用,而导出的纯辅助函数在工厂设计内保留了可单元测试性。 +- **可注入的 ACP `Client` 工厂,而非声明式 `permissionAnswers`**:灵活性最大,但将 SDK 客户端构造泄露给每个消费方,并在正被统一的层面重新引入逐示例漂移;声明式队列使 `input.json` 成为唯一的脚本化界面,且可被 golden 规范化。 +- **泛化到 ACP 之外(传输无关的快照 harness)**:不存在第二种传输方式;harness 端到端都是 ACP 形态(SDK 客户端、JSON-RPC 帧、`session/update` 等待器),推测性的抽象将是一个超前于任何消费方的 seam 拆分。 ## 测试 -提取保留了所有既有 ACP golden 的每一个字节。包的 `src/` 通过脚本化的 ACP 子进程实现逐文件 100% 覆盖:harness 测试覆盖每个步骤操作、两个预期错误分支、权限选择/回退/不可能选项、环境变量转发、工作区种子注入与收集排序/噪声/回退;suite 测试对已提交的合成 fixture 执行 replay,并对临时副本执行 record,加上纯辅助函数的测试。两个结构上不可达的守卫保留了有理由的覆盖率排除。fake agent 将 `session/new` 的 cwd 替换进日志,包括 Darwin 的 `/var` realpath 行为,与真实 bin 一致。 +提取保留了所有既有 ACP golden 字节。包的 `src/` 通过脚本化的 ACP 子进程达到逐文件 100% 覆盖率:harness 测试覆盖每个步骤操作、两条预期错误分支、权限选择/回退/不可能选项、环境变量转发、workspace 种子注入、收集排序/噪声/回退;suite 测试对已提交的合成 fixture 执行 replay,并对临时副本执行 record,同时覆盖纯辅助函数。两个结构上不可达的守卫保留了有理由的覆盖率排除。fake agent 将 `session/new` 的 cwd 替换到日志中,包括 Darwin 的 `/var` realpath 行为,与真实 bin 一致。 ## 后果 -新示例只需一张场景表加 fixture 即可获得完整的快照层——sandbox 分支从 master 合并后添加自己的套件(自己的 pin 场景、自己的覆盖层、通过 `test:snapshot:record` 生成 fixture、通过 `permissionAnswers` 指定审批答案)。代价:`suite.ts` 导入 vitest,因此该包只能在 vitest 运行中被导入——这是其他包没有的形态,已在其 README 中声明;每个套件 pin 自己的约 8 KB header fixture(真正不同的组合理应有自己的 pin;相同的组合会被该套件的一致性守卫捕获);e2e 启动器的重复仍然存在(`TODO(acp-test-harness)`)——当该迁移落地时,harness 是提取目标。 +新示例只需一张场景表加 fixture 即可获得完整快照层——sandbox 分支从 master 合入后添加自己的套件(自己的 pin 场景、自己的 overlay、通过 `test:snapshot:record` 生成 fixture、通过 `permissionAnswers` 提供审批答案)。代价:`suite.ts` 导入 vitest,因此该包只能在 vitest 运行中导入——这是其他包没有的形态,已在其 README 中声明;每个套件 pin 自己约 8 KB 的 header fixture(真正不同的组合值得拥有自己的 pin;相同的组合会被该套件的一致性守卫捕获);e2e launcher 的重复仍然存在(`TODO(acp-test-harness)`)——当该迁移落地时,harness 即为提取目标。 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index 5b46f88338..59f21c938e 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-16-typed-event-schemas.md: 8d14c3b2d90d8dcf295e122e95267c2c0d7b2a17 -2026-06-16-typed-event-schemas.zh.md: 87bd6b89a2332b7a3a609a9c36d1e9835e42a0b1 +2026-06-16-typed-event-schemas.zh.md: 34f46a87058b409ecdab38d851654db49d87e201 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index 87bd6b89a2..34f46a8705 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -1,4 +1,4 @@ -# RFC:事件词汇的运行时 schema(Zod 与 merge-extensible-map 模式之争) +# RFC:事件词汇的运行时 schema(Zod 与 merge-extensible-map 模式之辩) [English](2026-06-16-typed-event-schemas.md) | 中文 @@ -6,72 +6,72 @@ Status: proposed ## 问题 -harness 将其核心词汇——内容块、消息来源、结束原因、轮次触发器、轮次结束原因与会话事件——建模为 **merge-extensible map**:一个 TypeScript `interface`(如 `SessionEventMap`、`ContentBlockMap`),插件通过声明合并对其扩展,公开联合类型以 `Map[keyof Map]` 派生。这是本仓库的通用扩展模式,记录在 [docs/architecture.md](../../../architecture.md) 中("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`"),并被 `defineTool` 的 `InferArgs` DSL 与 `assertNever` 穷尽性约定所依赖。 +harness 将其核心词汇——内容块、消息来源、结束原因、轮次触发器、轮次结束原因与会话事件——建模为 **merge-extensible map**:一个 TypeScript `interface`(如 `SessionEventMap`、`ContentBlockMap`),插件通过声明合并对其扩展,公开联合类型则以 `Map[keyof Map]` 派生。这是本仓库的通用扩展模式,记录在 [docs/architecture.md](../../../architecture.md) 中("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`"),`defineTool` 的 `InferArgs` DSL 和 `assertNever` 穷举约定都依赖于它。 -该模式**仅存在于编译期**。类型在运行时消失:没有 schema 对象可供校验传入值、解析不可信输入或在运行时枚举。[会话持久化契约](../../implemented/architecture/2026-06-14-session-persistence.md)暴露了两个后果: +该模式**仅存在于编译期**。类型在运行时消失:没有 schema 对象可供校验传入值、解析不可信输入或在运行时枚举变体。[会话持久化契约](../../implemented/architecture/2026-06-14-session-persistence.md)暴露了两个后果: -1. **持久化将 `event.data` 视为不透明 JSON。** JSONL/SQLite 后端对每个事件逐字 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性——拒绝 BigInt、函数、循环引用、非有限数等),而**不是**结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在之后被消费方的 `switch` 处理时才可能被发现。 -2. **插件新增的变体没有运行时契约。** 一个通过声明合并添加新 `SessionEventMap` 键的插件,在自身代码中获得了编译期类型,但没有任何机制校验它产出的值是否匹配它声明的形状——无论在生产端、持久化边界还是重新加载时。 +1. **持久化将 `event.data` 视为不透明 JSON。** JSONL/SQLite 后端对每个事件逐字 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性检查:拒绝 BigInt、函数、循环引用、非有限数等),而**非**结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在后续消费方的 `switch` 中才可能被捕获。 +2. **插件新增变体没有运行时契约。** 一个通过声明合并添加新 `SessionEventMap` 键的插件,在自身代码中获得了编译期类型,但没有任何机制校验它产出的值是否符合它所声明的形状——无论是在生产者处、持久化边界处还是重新加载时。 -由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化边界与插件边界拥有运行时 schema 而非被擦除的类型。 +由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化和插件边界拥有运行时 schema 而非被擦除的类型。 -本 RFC 界定这一问题的范围,不提出具体实现。 +本 RFC 界定该问题的范围,不提出具体实现。 -## 为什么这不是一个持久化变更 +## 为什么这不是一个持久化层的改动 -很容易把「用 Zod 做序列化」理解为对 `dsh-session-persistence-jsonl/src/format.ts` 的局部改动。但它不是,原因在于一个结构性事实:**插件无法通过声明合并扩展一个 Zod schema。** 声明合并是 TypeScript 的编译期机制;Zod schema 是运行时值。要用 Zod 校验事件,你需要一个**运行时注册表**,每个产出事件的包向其贡献自己的 schema(如 `ctx.sessionEvents.register('compaction/marker', z.object({…}))`),每个消费方从中读取。这个注册表——而非持久化后端——将成为词汇的真源,取代 merge-extensible interface。 +很容易把「用 Zod 做序列化」理解为对 `dsh-session-persistence-jsonl/src/format.ts` 的局部修改。但它不是,原因在于一个结构性事实:**插件无法对 Zod schema 进行声明合并。** 声明合并是 TypeScript 编译期机制;Zod schema 是运行时值。要用 Zod 校验事件,就需要一个**运行时注册表**,每个产出事件的包(package)向其贡献自己的 schema(如 `ctx.sessionEvents.register('compaction/marker', z.object({…}))`),每个消费方从中读取。这个注册表——而非持久化后端——将成为词汇的真源,取代 merge-extensible interface。 -因此真正的提案是:**用运行时 schema 注册表替换编译期的 merge-extensible-map 模式,覆盖全仓库。** 这是一次核心词汇的重新设计。 +因此,真正的提案是:**用运行时 schema 注册表替换编译期的 merge-extensible-map 模式,范围覆盖整个仓库。** 这是一次核心词汇的重新设计。 -## 影响范围(实测) +## 影响范围(已度量) 将事件/词汇表面迁移到运行时 schema,至少涉及: -- **六个 merge-extensible map**(约 370 行核心类型):`ContentBlockMap`、`MessageSourceMap`、`FinishReasonMap`(在 `dsh-llm` 中);`TurnTriggerMap`、`TurnEndReasonMap`、`SessionEventMap`(在 `dsh-session` 中)。 -- **约 10 个 `declare module` 扩展点**,分布在 `dsh-agent`、`dsh-agent-loop`、`dsh-bash`、`dsh-llm`、`dsh-session`、`dsh-session-persistence`、`dsh-system-prompt`、`dsh-tools` 中——每个都将从声明合并改为运行时 `register()` 调用。 -- **事件生产端**——agent loop 中 16 处 `session.append(...)` 调用点——形状不变,但现在在边界处被校验。 -- **约 7 个 switch 消费方**,按这些联合类型分支:`deriveMessages`(`dsh-session`)、`BlockAssembler`(`dsh-llm`)、`dsh-invariants` 插件、两个 LLM 适配器(`dsh-llm-deepseek`、`dsh-llm-pi-ai`)以及工具 schema 层(`dsh-tools`)。`assertNever` 对封闭联合的穷尽性 vs 对可扩展联合的 fall-through 约定(一条已文档化的 lint 规则)需要重新考量——运行时变体不具备静态穷尽性。 -- **`defineTool` 的 `InferArgs` DSL**(`dsh-tools`),它从编译期 schema 规格派生零强制转换的 `execute` 参数类型——这是当前方案的标杆用例。 -- **文档**:architecture.md(该模式被描述为基础性的)、[开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md),以及任何引用该模式的 RFC。 +- **六个 merge-extensible map**(约 370 行核心类型):`ContentBlockMap`、`MessageSourceMap`、`FinishReasonMap`(位于 `dsh-llm`);`TurnTriggerMap`、`TurnEndReasonMap`、`SessionEventMap`(位于 `dsh-session`)。 +- **约 10 处 `declare module` 扩展点**,分布在 `dsh-agent`、`dsh-agent-loop`、`dsh-bash`、`dsh-llm`、`dsh-session`、`dsh-session-persistence`、`dsh-system-prompt`、`dsh-tools` 各包中——每处都将从声明合并改为运行时 `register()` 调用。 +- **事件生产者**——agent loop(智能体循环)中 16 处 `session.append(...)` 调用——形状不变,但现在在边界处被校验。 +- **约 7 个 switch 消费方**,对这些联合类型进行分支:`deriveMessages`(`dsh-session`)、`BlockAssembler`(`dsh-llm`)、`dsh-invariants` 插件、两个 LLM(大语言模型)适配器(`dsh-llm-deepseek`、`dsh-llm-pi-ai`)以及工具 schema 层(`dsh-tools`)。`assertNever` 对封闭联合类型的穷举 vs 对可扩展联合类型的 fall-through 约定(一条已记录的 lint 规则)需要重新考量——运行时变体在静态层面不可穷举。 +- **`defineTool` 的 `InferArgs` DSL**(`dsh-tools`),它从编译期 schema 规范派生出零类型转换的 `execute` 参数类型——这是当前方案的标杆用例。 +- **文档**:architecture.md(该模式被描述为基础性的)、[dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md),以及所有引用该模式的 RFC。 -这是一次仓库级别的词汇重新设计,不是持久化的实现细节。 +这是一次仓库级别的词汇重新设计,而非持久化的实现细节。 ## 曾考虑的替代方案 -### A. 维持现状——merge-extensible 类型 + 持久化边界的 `isJsonValue` -保留编译期模式。持久化继续使用不透明 JSON + 可序列化性守卫。插件通过声明合并扩展;事件*形状*的正确性由生产方负责,在编译期由 TypeScript 强制,在开发模式下由 `dsh-invariants` 插件的结构检查强制。 +### A. 维持现状——merge-extensible 类型 + 持久化边界处 `isJsonValue` +保留编译期模式。持久化继续使用不透明 JSON + 可序列化性守卫。插件通过声明合并扩展;事件*形状*的正确性由生产者负责,编译期由 TypeScript 保证,开发模式下由 `dsh-invariants` 插件的结构检查保证。 -- **优点**:零变更;插件扩展只需一行 `interface` 声明合并,具备完整类型推断且无运行时注册仪式;无新运行时依赖;`defineTool` DSL 与 `assertNever` 穷尽性保持正常工作。 +- **优点**:零变动;插件扩展只需一行 `interface` 增补,享有完整类型推断,无需运行时注册仪式;无新运行时依赖;`defineTool` DSL 与 `assertNever` 穷举继续工作。 - **缺点**:持久化边界和插件 seam 处无运行时结构校验;格式错误但仍为合法 JSON 的数据被延迟捕获。 -### B. 仅对 header/封闭形状做校验(schemastery),事件保持不透明 -仅对那些已有手写类型守卫的真正封闭形状加强校验——例如 JSONL 的 `HeaderLine` 守卫(`isHeaderLine`)——使用 **schemastery**(本仓库现有的 schema 库,已用于每个插件的 `static Config`)。merge-extensible 事件联合保持不变。 +### B. 仅对头部/封闭形状做校验(schemastery),事件仍为不透明 +仅对那些已有手写类型守卫的真正封闭形状加以收紧——例如 JSONL 的 `HeaderLine` 守卫(`isHeaderLine`)——使用 **schemastery**(仓库现有的 schema 库,已用于每个插件的 `static Config`)。merge-extensible 事件联合类型保持不变。 -- **优点**:改动小,契合既有约定(schemastery,非新库);用声明式 schema 替换封闭形状上的手写守卫;无核心重设计。 -- **缺点**:不解决事件数据的校验问题;仅固定的元数据记录得到改善。 +- **优点**:改动小,契合现有约定(schemastery,而非新库);用声明式 schema 替换封闭形状上的手写守卫;无核心重新设计。 +- **缺点**:不解决事件数据校验问题;仅固定的元数据记录得到改善。 ### C. 为整个词汇建立运行时 schema 注册表(Zod 或 schemastery) -用运行时注册表替换 merge-extensible map,生产方向其贡献 schema,持久化/消费方据其校验。 +用运行时注册表替换 merge-extensible map,生产者向其贡献 schema,持久化/消费路径据此校验。 -- **优点**:持久化边界与插件 seam 处有真正的运行时校验;单一真源;支持通用工具(自动生成文档、模糊测试、协议格式检查)。 -- **缺点**:上述完整影响范围;**Zod 目前不是直接依赖**(仅作为 `@earendil-works/pi-ai` 的传递依赖),本仓库选定的 schema 库是 **schemastery**——广泛引入 Zod 本身就是一个依赖决策;声明合并的人体工学(一行插件扩展、完整推断)被运行时注册 + 手动类型接线取代;`assertNever` 穷尽性保证弱化(运行时变体不具备静态穷尽性)。 +- **优点**:持久化边界和插件 seam 处获得真正的运行时校验;单一真源;可支撑通用工具(自动生成文档、模糊测试、协议格式检查)。 +- **缺点**:上述全部影响范围;**Zod 目前不是直接依赖**(仅作为 `@earendil-works/pi-ai` 的传递依赖),仓库选定的 schema 库是 **schemastery**——广泛引入 Zod 本身就是一个依赖决策;声明合并的人体工学(一行插件扩展、完整推断)被运行时注册 + 手动类型接线取代;`assertNever` 穷举保证弱化(运行时变体在静态层面不可穷举)。 ## 提案 -暂缓。如果需要在持久化边界做运行时校验,**方案 B**(用 schemastery 校验封闭的 header 与元数据形状)是既有约定内的适度步骤。**方案 C** 是一项架构决策,需要自己的实现 RFC,包括在 Zod 与 schemastery 之间做出选择。 +推迟。如果需要在持久化边界做运行时校验,**方案 B**(对封闭的头部和元数据形状使用 schemastery)是现有约定下的适度步骤。**方案 C** 是一个架构决策,需要自己的实现 RFC,其中包括 Zod 与 schemastery 之间的选择。 ## 验收标准 -- 方案 C 只能通过自己的实现 RFC 推进,绝不作为持久化的附带效果。 -- 如果采纳方案 B,封闭的 header/元数据形状(JSONL 的 `isHeaderLine` 守卫及同类)改用 schemastery 校验以替代手写守卫,merge-extensible map 保持不变。 +- 方案 C 只能通过自己的实现 RFC 推进,绝不能作为持久化的附带改动。 +- 如果采纳方案 B,封闭的头部/元数据形状(JSONL 的 `isHeaderLine` 守卫及同类)改用 schemastery 校验,替代手写守卫,merge-extensible map 保持不动。 ## 风险 -- 暂缓意味着事件 `data` 在持久化边界仍无结构校验:格式错误但仍为合法 JSON 的数据被延迟捕获,由消费方的 `switch` 处理——这是现状的代价,有意接受。 -- 如果方案 C 最终被采纳,人体工学损失是实际的:一行声明合并变为运行时注册加手动类型接线,`assertNever` 的静态穷尽性保证弱化。 +- 推迟意味着事件 `data` 在持久化边界处仍无结构校验:格式错误但仍为合法 JSON 的数据被延迟捕获,由消费方的 `switch` 兜底——这是现状的代价,有意接受。 +- 如果方案 C 最终被采纳,人体工学的损失是真实的:一行声明合并变为运行时注册加手动类型接线,`assertNever` 的静态穷举保证弱化。 ## 待解问题 -- 如果采用注册表,schema 库选 **schemastery**(已在依赖树中,已是配置 schema 库)还是 **Zod**(生态更丰富,目前仅为传递依赖)?同时维护两个 schema 库本身就是成本。 -- 能否采用混合方案:保留编译期推断(使 `defineTool` 和插件 DX 不受影响),同时为每个变体添加*可选*的运行时 schema,仅在持久化/协议边界校验而非每次进程内 append 时校验? -- `dsh-invariants` 插件在开发模式下是否已覆盖了足够多的运行时形状缺口,使得边界校验仅在面对真正不可信的输入(如重新加载被外部修改的日志)时才有必要? +- 如果采用注册表,库选 **schemastery**(已在仓库中,已作为配置 schema 库)还是 **Zod**(生态更丰富,目前仅为传递依赖)?同时维护两个 schema 库本身就是一种成本。 +- 能否采用混合方案:保留编译期推断(使 `defineTool` 和插件开发体验不受影响),同时为每个变体添加*可选*的运行时 schema,仅在持久化/协议边界校验,而非每次进程内 append 都校验? +- `dsh-invariants` 插件在开发模式下是否已覆盖了足够多的运行时形状缺口,使得边界校验仅在面对真正不可信输入(重新加载外部修改过的日志)时才有必要? diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index edc5a3604d..290a729be7 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generic-long-running-tool-runtime.md: ea773a651b5aeec87179aac2ed419f176486977f -2026-06-20-generic-long-running-tool-runtime.zh.md: d50eb6858c11b9c98817b24bfe40f4e2c780f4b9 +2026-06-20-generic-long-running-tool-runtime.zh.md: 25c1b282b19bb7da08b552485e348c23b11dcecf diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index d50eb6858c..25c1b282b1 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -1,43 +1,43 @@ -# RFC:提取通用的长时运行工具运行时 - -[English](2026-06-20-generic-long-running-tool-runtime.md) | 中文 +# RFC:提取通用的长时间运行工具运行时 Status: proposed +[English](2026-06-20-generic-long-running-tool-runtime.md) | 中文 + ## 问题 -bash 能力 seam 同时支持前台命令和长时运行的后台任务。后台支持体量不小:抽象执行器暴露 `start`、`get`、`ownerOf`、`list`、`readOutput`、`kill` 和 `onTaskDone`;本地执行器跟踪任务、增量读取、owner token、进程清理和完成监听器;模型侧看到三个工具(`bash`、`bash_output`、`bash_kill`);工具插件将完成通知注入回所属 agent 的会话。本地执行器用 owner token 隔离任务访问,因为可预测的全局 task id 会带来跨会话的读取/终止风险。 +bash 能力 seam 同时支持前台命令和长时间运行的后台任务。后台支持体量不小:抽象执行器暴露 `start`、`get`、`ownerOf`、`list`、`readOutput`、`kill` 和 `onTaskDone`;本地执行器负责跟踪任务、增量读取、owner token、进程清理和完成监听;模型侧看到三个工具(`bash`、`bash_output`、`bash_kill`);工具插件将完成通知注入回所属 agent(智能体)的会话。本地执行器用 owner token 隔离任务访问,因为可预测的全局 task id 会带来跨会话的读取/终止风险。 -[工具实操手册](../../../cookbook/adding-a-tool.md)已经指出了真正的设计异味:后台 bash 实质上是寄居在一个工具内部的通用长时运行工具基础设施。如果未来的工具也需要后台执行、轮询、终止、所有权和完成通知,这些语义不应藏在 `dsh-bash` 里。 +[工具实操手册](../../../cookbook/adding-a-tool.md)已经指出了真正的设计异味:后台 bash 本质上是寄居在单个工具内部的通用长时间运行工具基础设施。如果未来的工具也需要后台执行、轮询、终止、所有权和完成通知,这些语义不应藏在 `dsh-bash` 里。 ## 提案 -将长时运行任务的语义从 bash 上方抽出,放入一个与工具无关的运行时。bash 仍然能运行后台命令,但不再拥有 task id、ownership token、轮询、取消、完成通知以及模型侧「读取/终止此任务」命令等通用概念。 +将长时间运行任务的语义从 bash 上移到一个与工具无关的运行时中。bash 仍然能运行后台命令,但不再拥有 task id、ownership token、轮询、取消、完成通知以及模型侧「读取/终止此任务」命令等通用概念。 该运行时应拥有: -- 稳定的 task id 与 owner token,按调用方的会话/agent 键控。 -- 注册一个长时运行任务,附带增量输出的生产者和一个完成 promise。 +- 稳定的 task id 和 owner token,按调用方的会话/agent 做键。 +- 注册一个长时间运行任务,附带增量输出的生产者和一个完成 promise。 - 通用的 read/cancel/list 操作,对所有工具使用相同的跨会话授权规则。 - 向所属会话注入完成通知。 -- 待处理/运行中/已完成任务状态的展示钩子,bash 只提供命令特有的标签和输出格式化。 +- 针对 pending/running/completed 任务状态的展示钩子,bash 只提供命令特有的标签和输出格式化。 -`dsh-bash` 随后只保留 bash 特有的执行契约:将请求解析为命令规格、运行前台命令,或启动进程并将其流/进程句柄交给通用运行时。`dsh-tool-bash` 保留模型侧的命令工具,但后续操作变为通用的长时运行工具操作(或 bash 向其注册的共享工具层),而非定制的 `bash_output`/`bash_kill` 管道。 +`dsh-bash` 保留 bash 特有的执行契约:将请求解析为命令规格、运行前台命令,或启动进程并将其流/进程句柄交给通用运行时。`dsh-tool-bash` 保留模型侧的命令工具,但后续操作变为通用的长时间运行工具操作,或者 bash 向其注册的共享工具,而不是专属的 `bash_output`/`bash_kill` 管道。 ## 当前 seam 消费情况 -当前消费方划分清晰:`dsh-tool-bash` 使用完整的前台/后台 seam,而钩子桥接只使用前台的 `resolve` 和 `run`(带受信的 `stdin` 与 `env`)。`get` 和 `list` 仅在测试中使用;`BashTask.done` 仅在实现内部用于 dispose(资源释放),生产环境的完成通知走 `onTaskDone`。提取出的运行时应暴露单一的公开完成机制,保留钩子所需的简单前台路径,并决定后台的 `timeoutMs` 是否属于 `start`。如果运行时拥有进程 spawn,还应集中处理目前重复的凭证清洗逻辑。 +当前消费方划分清晰:`dsh-tool-bash` 使用完整的前台/后台 seam,而钩子桥接层只使用前台的 `resolve` 和 `run`(带受信的 `stdin` 和 `env`)。`get` 和 `list` 仅在测试中使用;`BashTask.done` 仅在实现内部用于 dispose(资源释放),生产环境的完成通知使用 `onTaskDone`。提取出的运行时应暴露一个公开的完成机制,保留钩子所需的简单前台路径,并决定后台的 `timeoutMs` 是否属于 `start`。如果它拥有进程 spawn 的职责,还应集中处理目前重复的凭证清洗逻辑。 ## 验收标准 -- bash 特有的包不再定义通用的任务注册表、owner-token 授权、轮询、取消或完成通知机制。 -- 一个共享的长时运行任务服务或工具层拥有这些语义,并作为未来任何具备后台能力的工具的文档化路径。 -- bash 的后台行为仍可通过共享层使用,测试证明跨会话隔离依然成立。 -- ACP 和快照 fixture(测试前置数据)通过共享的任务词汇渲染后台 bash,而非通过 bash 独有的生命周期语义。 -- [工具实操手册](../../../cookbook/adding-a-tool.md)将长时运行工具指向共享运行时,而非告诉每个工具自行发明任务协议。 +- bash 特有的包(package)不再定义通用的任务注册表、owner-token 授权、轮询、取消或完成通知机制。 +- 一个共享的长时间运行任务服务或工具层拥有这些语义,并被文档化为未来任何具备后台能力的工具的接入路径。 +- bash 后台行为仍可通过共享层使用,测试证明跨会话隔离依然成立。 +- ACP 和快照 fixture(测试前置数据)通过共享任务词汇渲染后台 bash,而非通过 bash 专属的生命周期语义。 +- [工具实操手册](../../../cookbook/adding-a-tool.md)将长时间运行工具指向共享运行时,而不是让每个工具自行发明任务协议。 ## 风险 -bash 包失去了对一个已经可用的后台任务实现的本地所有权,实施 PR 可能暂时搅动模型侧的工具名称或 transcript(文本记录)展示。如果最终结果是留下一份后台任务契约、而非让每个未来的长时运行工具克隆 bash 的私有协议,这种搅动是值得的。 +bash 包失去了对一个已经可用的后台任务实现的本地所有权,实现 PR(Pull Request)可能暂时搅动模型侧的工具名称或 transcript(文本记录)展示。如果最终结果是留下一份后台任务契约,而不是让每个未来的长时间运行工具克隆 bash 的私有协议,这种搅动是值得的。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml index 2c12df37b1..ccab930c8f 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-pre-tool-input-rewrite.md: add84bfc76434eb25870f09e71860279663d291e -2026-06-30-pre-tool-input-rewrite.zh.md: c636cf42d5c55f0ee1192a46283f8cdbe8c4cadc +2026-06-30-pre-tool-input-rewrite.zh.md: 13d66208be07992fd414f2984c493557ad1e87a3 diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md index c636cf42d5..13d66208be 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md @@ -1,53 +1,53 @@ -# RFC:工具执行前输入改写——一致性设计 - -[English](2026-06-30-pre-tool-input-rewrite.md) | 中文 +# RFC:工具执行前输入重写——一致性设计 Status: proposed +[English](2026-06-30-pre-tool-input-rewrite.md) | 中文 + ## 问题 -[拦截 seam RFC](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道 allow/deny/ask 门禁,作用于身份已受保护、参数已被深度冻结的执行对象。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的改写机制。改写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 +[拦截 seam RFC](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 ## 问题本质:执行前参数的三个读取方 -在 agent loop(智能体循环)中,工具调用的参数在工具执行之前就已被提交到日志并被活跃消费方读取: +在 agent loop(智能体循环)中,工具调用的参数在工具执行**之前**就已提交到日志并被实时消费方读取: -1. **`assistant/message`** 在工具分发之前追加——它是 `deriveMessages()` 回放时的模型历史来源,因此携带的是模型自身生成的工具调用参数。 +1. **`assistant/message`** 在工具分发之前追加——它是 `deriveMessages()` 回放时的模型历史来源,因此携带模型自身输出的工具调用参数。 2. **`tool/call`** 是持久化的审计记录,在 `ctx.tools.execute()` 之前追加。 -3. **展示层实时读取 `tool/call.arguments`**:ACP 桥接会记住这些参数并传给 `presentResult`;`dsh-tool-bash` 从中派生卡片标题、rawInput、cwd 以及终端/后台的处理方式。 +3. **展示层实时读取 `tool/call.arguments`**:ACP(Agent Client Protocol)桥接记住这些参数并传给 `presentResult`;`dsh-tool-bash` 从中派生卡片标题、rawInput、cwd 以及终端/后台处理方式。 -如果只做执行层面的改写,UI 会展示一条命令而实际运行的是另一条,并且结果会对着错误的参数渲染。注册表目前阻止了这种失败模式:它对 `arguments` 做 structured-clone 并深度冻结,将执行身份属性设为不可写,且不暴露任何可替换它们的测试 shim 或监听路径。改写设计必须保持这一受保护的身份边界,而非削弱它。 +如果只做执行层面的重写,UI 会显示一条命令而实际运行的是另一条,并且结果会对着错误的参数渲染。注册表目前通过以下方式防止这种失败模式:对 `arguments` 做 structured-clone 并深度冻结,将执行身份属性设为不可写,且不暴露任何可替换它们的测试 shim 或监听路径。重写设计必须维护这一受保护的身份边界,而非削弱它。 ## 提案 -改写是一次「身份构造前的一致性事务」。当钩子提供 `updatedInput` 时,有效值必须在注册表构造不可变的 `ToolExecution` 之前确定,并原子性地反映到全部三个读取方: +重写是一个「身份标识创建前的一致性事务」。当钩子提供 `updatedInput` 时,有效值必须在注册表构造其不可变的 `ToolExecution` 之前确定,并且必须原子地反映到全部三个读取方: -- `tool/call` 审计事件记录**改写后**的参数(原始参数保留在一个 sidecar 字段中用于审计追踪——钩子改变了调用,原始参数和生效参数都是值得保留的事实)。 -- 派生历史中的 `assistant/message` 必须与实际执行一致——待评估的选项:就地改写 assistant 消息中的工具调用块(改变模型「看到自己说过的话」),或记录一条单独的修正由下一次请求携带。CC 的模型是让模型看到改写已生效。 -- 展示层(`presentCall`/`presentResult`)读取改写后的参数,UI 展示的是实际运行的内容。 +- `tool/call` 审计事件记录**重写后**的参数(原始参数保留在一个伴随字段中,作为审计线索——钩子修改了调用,原始参数与生效参数都是值得保留的事实)。 +- 派生历史中的 `assistant/message` 必须与实际执行一致。待评估的选项:就地重写 assistant 消息中的工具调用块(改变模型「看到自己说了什么」),或记录一条单独的修正让下一次请求携带。Claude Code 的模型是让模型看到重写已生效。 +- 展示层(`presentCall`/`presentResult`)读取重写后的参数,使 UI 显示实际运行的内容。 -在 `PreToolDecision` 当前的触发点上做扩展不够:此时两条持久化记录都已存在,执行身份已受保护。实现必须要么将相关决策移到日志提交之前,要么在待处理的模型调用上增加一个专门的更早期改写决策。当循环将生效参数提交到历史和审计之后,再按常规构造不可变执行对象,并照常运行现有的 allow/deny/ask 与工具流水线。 +在 `PreToolDecision` 当前的触发点上做扩展是不够的:此时两条持久化记录已经存在,执行身份已受保护。实现必须将相关决策移到日志提交之前,或者增加一个专门的、更早的重写决策点来处理待定的模型调用。agent loop 将生效参数提交到历史和审计之后,再构造普通的不可变执行对象,并照常运行现有的允许/拒绝/询问和工具流水线。 ## 曾考虑的替代方案 ### 为什么不直接修改执行对象? -允许 pre-execute 监听器赋值 `exec.arguments` 只能提供执行层面的改写,模型历史、审计和展示层不会跟着变。保持身份受保护使得这种局部行为无法被表达。在一致性事务实现之前,CC/Codex 桥接对 `updatedInput` 只做日志记录并发出警告,而非声称已兑现;循环分发处的 `TODO(pre-tool-input-rewrite)` 锚定了这个缺失的更早阶段。 +允许 pre-execute 监听器赋值 `exec.arguments` 只能提供执行层面的重写,模型历史、审计和展示层不会随之改变。保持身份标识受保护使得这种局部行为不可表达。在一致性事务实现之前,CC/Codex 桥接对 `updatedInput` 记录日志并发出警告,而非声称已兑现;循环分发点的 `TODO(pre-tool-input-rewrite)` 标记了缺失的更早阶段。 ## 验收标准 -- 请求的改写在 `ToolExecution` 身份创建之前完成解析,并原子性地反映到全部三个读取方:`tool/call` 审计记录改写后的参数(原始参数保留在 sidecar 字段)、派生历史与实际执行一致、展示层渲染改写后的参数。 -- 生效的 `ToolExecution.arguments` 在 pre-policy、guards、dispatch、post-policy 和最终观测的全过程中保持深度冻结且不可写;不引入任何可变 shim。 -- CC/Codex 桥接兑现 `updatedInput`,不再输出忠实但降级的警告。 +- 请求的重写在 `ToolExecution` 身份标识创建之前解决,并原子地反映到全部三个读取方:`tool/call` 审计记录重写后的参数(原始参数保留在伴随字段中)、派生历史与实际执行一致、展示层渲染重写后的参数。 +- 生效的 `ToolExecution.arguments` 在 pre-policy、守卫、分发、post-policy 和最终观测全程保持深度冻结且不可写;不引入任何可变 shim。 +- CC/Codex 桥接兑现 `updatedInput`,不再记录忠实但降级的警告。 ## 风险 -- 改写 `assistant/message` 中的工具调用块会改变模型「看到自己说过的话」;是否有提供方在回放时拒绝这种改写,是一个必须在决策形态冻结前通过实验验证的开放问题。 -- 更早期的改写阶段改变了 `assistant/message`、`tool/call`、钩子审计事件与执行之间的顺序关系;设计必须固定这一顺序,同时不削弱轮次封闭性或 call/result 邻接性。 +- 重写 `assistant/message` 中的工具调用块会改变模型「看到自己说了什么」;是否有提供方在回放时拒绝这种改动,是一个需要通过实验确定的开放问题,必须在决策形状冻结之前解决。 +- 更早的重写阶段改变了 `assistant/message`、`tool/call`、钩子审计事件与执行之间的顺序关系;设计必须固定这一顺序,同时不削弱轮次封闭性或调用/结果邻接性。 ## 开放问题 -- 改写 `assistant/message` 中的工具调用块是否会破坏某些提供方在回放时的预期?还是记录一条单独的修正更安全? -- 原始参数是否应保留在 `tool/call` 事件(审计)上?如果是,放在哪个字段? -- 改写决策是移到日志提交之前,还是成为一个专门的更早期 seam?现有的 pre-tool allow/deny 钩子如何避免运行两次? -- 这与未来的权限 `ask` 流程(用户批准一个被改写的调用)如何交互? +- 重写 `assistant/message` 中的工具调用块是否会破坏某些提供方在回放时的预期?还是单独的修正更安全? +- 原始参数是否应保留在 `tool/call` 事件(审计)上?如果是,放在什么字段? +- 重写决策是移到日志提交之前,还是成为一个专门的更早 seam?现有的 pre-tool 允许/拒绝钩子如何避免运行两次? +- 这与未来的权限 `ask` 流程(用户批准一个被重写的调用)如何交互? diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index d7b4dcf71f..ab362ca036 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-claude-code-and-codex-subagent-backends.md: 1ebf01dd8df0980f6c464be8b27033bdfab942f3 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 0ed5b42bc9d60b54ac610a8f34f8261f3aeefaed +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: dd26a49962ee46a8ce0965557ff3dbd5805fdcfe diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 0ed5b42bc9..dd26a49962 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -1,4 +1,4 @@ -# RFC:Claude Code 与 Codex subagent 后端(进程外委派至外部编码 agent) +# RFC:Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派) [English](2026-07-07-claude-code-and-codex-subagent-backends.md) | 中文 @@ -6,84 +6,84 @@ Status: proposed ## 问题 -为 Claude Code 和 Codex 添加隔离的 subagent 提供方。既有的[命名提供方 seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [ACP 后端](../../implemented/feature/2026-06-22-acp-subagent-backend.md)已确立了进程边界的形状。harness 的一个轮次应当能够将一个自包含的任务委派给上述任一产品,并接收其最终回答,同时不暴露父进程的密钥,也不继承来自 `~/.claude` 或 `~/.codex` 的宿主配置。 +为 Claude Code 和 Codex 添加隔离的 subagent 提供方。既有的[命名提供方 seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [ACP 后端](../../implemented/feature/2026-06-22-acp-subagent-backend.md)已确立了进程边界的形状。harness 的一个轮次应能将一个自包含任务委派给上述任一产品,并接收其最终答案,同时不暴露父进程的密钥,也不继承来自 `~/.claude` 或 `~/.codex` 的宿主配置。 -## 方案 +## 提案 两个兄弟提供方包(ACP 后端的结构变体),加一次提取: -- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其捆绑的 `claude` CLI 作为子进程 spawn)。提供方名称 `claude-code`:子进程是 Claude Code 这个**产品**,而非 Anthropic 模型适配器——"claude" 保留给未来的 `dsh-llm` 适配器。 -- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议,使用包内一个手写的换行 JSON 客户端(约 200–300 行)驱动一个 thread/turn。 -- `@deepseek-ai/dsh-subagent-process`:纯库(`subagent-inprocess` 先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`SENSITIVE_ENV_PATTERN`/`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose 阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 +- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI 作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个**产品**,而非 Anthropic 模型适配器——"claude" 保留给未来的 `dsh-llm` 适配器。 +- `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 +- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`SENSITIVE_ENV_PATTERN`/`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose 阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 -两个提供方遵循 ACP 后端契约:每次 `start` 创建一个全新子进程、一次 prompt 往返、不继承父上下文也不声明可选能力、忽略 `request.parent` 和 `request.agentOptions`、使用随机品牌 agent id。`result` 永不 reject;子进程失败映射为 stop reason,原始错误送入 logger。每个提供方在不同的工具名下挂载 `dsh-tool-subagent`。工具结果是唯一新增的模型可见产物,因此不需要新的会话事件;工作区变更仍是 transcript 回放之外的环境副作用。 +两个提供方遵循 ACP 后端契约:每次 `start` 创建一个全新子进程、一次 prompt 往返、不继承父上下文也不声明可选能力、忽略 `request.parent` 和 `request.agentOptions`、使用随机的品牌化 agent id。`result` 从不 reject;子进程失败映射为 stop reason,原始错误送入 logger。每个提供方以不同的工具名挂载 `dsh-tool-subagent`。工具结果是唯一新增的模型可见产物,因此无需新的会话事件;工作区变更仍是 transcript(文本记录)回放之外的环境副作用。 ## 已验证的接口事实(固定版本) -两个集成面在本提案之前均已针对固定实现进行了验证——读取类型与捆绑源码、运行 keyless spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门控、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都重跑 keyless 套件以验证真实加载路径——运行时则通过大声失败来保障:协议层的意外通过 `onError` 结算为 `error`,绝不静默异常。 +两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 -**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会**替换**子进程环境(不与 `process.env` 合并),这正是清洗所需的行为。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,若子进程忽略则约 2 秒后发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;二者均不在本 RFC 范围内。 +**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会**替换**子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 RFC 范围内。 **codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 - 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的 turn;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 -- 审批为服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 -- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证并大声结算 `error`,而非等待 turn。 -- 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),且 `ephemeral: true` 的 thread 完全不留会话文件。 +- 审批是服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 +- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端**必须**预检认证状态,并在失败时大声结算为 `error`,而非等待 turn。 +- 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。 ## 隔离与凭证 -认证仅使用 API key。每次运行使用一个全新的配置目录(Claude Code 用 `CLAUDE_CONFIG_DIR` 配合 `settingSources: []`,Codex 用 `CODEX_HOME`),dispose 时尽力删除;配置也可选择一个持久目录。共享的子进程环境辅助函数转发 `PATH`、`HOME`、`TMPDIR`、locale、代理设置等普通值,移除凭证形状的名称,并叠加显式的 `config.env`。Claude Code 通过该叠加接收 API key,Codex 则通过 `account/login/start` 接收,而非手写认证文件。 +认证方式仅限 API key。每次运行使用一个全新的配置目录(Claude Code 用 `CLAUDE_CONFIG_DIR` 配合 `settingSources: []`,Codex 用 `CODEX_HOME`),dispose 时尽力删除;配置也可以选择一个持久目录。共享的子进程环境辅助函数转发 `PATH`、`HOME`、`TMPDIR`、locale 和代理设置等普通值,移除凭证形态的名称,并叠加显式的 `config.env`。Claude Code 通过该叠加接收 API key,而 Codex 通过 `account/login/start` 接收,而非手写认证文件。 ## 权限与审批策略 -每个后端暴露其引擎的原生策略词汇。Claude Code 默认 `permissionMode: default` 配合 `permission: reject`;Codex 默认 `sandboxMode: read-only`、`approvalPolicy: never`,以及相同的拒绝回退。示例可选择启用 `acceptEdits` 或 `workspace-write`。已知的审批、用户输入和 elicitation 请求接收配置的应答;未知方法接收 method-not-found,未知通知被消费。没有 prompt 到达人类,子进程也不会因等待不可用的输入而无限挂起。 +每个后端暴露其引擎原生的策略词汇。Claude Code 默认 `permissionMode: default` 配合 `permission: reject`;Codex 默认 `sandboxMode: read-only`、`approvalPolicy: never`,以及相同的拒绝回退。示例可选择启用 `acceptEdits` 或 `workspace-write`。已知的审批、用户输入和 elicitation 请求接收配置的应答;未知方法接收 method-not-found,未知通知被消费。没有 prompt 到达人类,子进程也不会因等待不可用的输入而无限挂起。 ## StopReason 映射 -Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不算成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。 +Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不是成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` 为 `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。 -活性姿态,明确声明:teardown 时序是配置项,turn 时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意**不设** turn 时长或启动超时——与 ACP 一致:turn 期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent turn 合理地可达数分钟,且 Codex 认证预检已消除了唯一经验证的必然挂起场景;需要墙钟上限的部署从父进程取消即可。 +活性姿态,明确声明:teardown 时序是配置项,turn 时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但**刻意不设** turn 时长或启动超时——与 ACP 一致:turn 期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent turn 合理地可达数分钟,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。 ## 测试 每个适用层级都要求覆盖: -- **Keyless 单元/集成:** 通过真实 SDK 驱动一个假 Claude CLI,通过真实 wire 客户端驱动一个脚本化的 Codex app-server。在逐文件 100% 覆盖率下,覆盖往返、每个 stop 映射、两条取消路径及预中止、权限策略、未知消息、spawn 失败、reload 清理、导出形状、清洗后的环境、临时目录删除,以及 Codex 认证预检失败。 -- **带 key 的 e2e:** 每个真实引擎在 `acceptEdits` 或 `workspace-write` 下执行文件操作;跳过时命名缺失的二进制文件或 key,并断言无残留子进程。 -- **快照:** 以 `TODO(claude-code-subagent-replay)` 和 `TODO(codex-subagent-replay)` 延后,等待 [subagent 回放 RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md) 描述的进程特定回放形状。 +- **无密钥单元/集成测试:** 通过真实 SDK 驱动一个假 Claude CLI,通过真实协议客户端驱动一个脚本化的 Codex app-server。在逐文件 100% 覆盖率下,验证往返、每种 stop 映射、两条取消路径及预中止、权限策略、未知消息、spawn 失败、reload 清理、导出形状、清洗后的环境、临时目录删除,以及 Codex 认证预检失败。 +- **有密钥 e2e 测试:** 每个真实引擎在 `acceptEdits` 或 `workspace-write` 下执行文件操作;跳过时命名缺失的二进制或密钥,并断言无残留子进程。 +- **快照测试:** 标记为 `TODO(claude-code-subagent-replay)` 和 `TODO(codex-subagent-replay)` 推迟,等待 [subagent 回放 RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md) 描述的进程特定回放形状。 ## 曾考虑的替代方案 -### 为什么不用官方 `@openai/codex-sdk` 而是手写客户端? +### 为什么不用官方 `@openai/codex-sdk` 而手写客户端? -dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),且仓库先例(`hook-protocol`)是自有精简协议核心而非包装他人运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。 +dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),仓库先例(`hook-protocol`)是拥有薄协议核心而非包装他人的运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。 ### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)? -Claude Code 自己的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在**执行引擎**之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置中,保持 `dsh-tool-subagent` 文档化的一提供方一工具契约。人格式的类型选择器应当是针对工具的独立 RFC,而非后端。 +Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在**执行引擎**之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 RFC,而非针对后端。 -### 为什么不用登录态凭证和用户自己的配置? +### 为什么不用登录态凭证和用户自身的配置? -继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill、MCP 服务器)会让子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打一个隐式例外。仅 API key 加强制配置目录隔离保持了运行的可复现性;需要共享状态的部署可以刻意将配置目录字段指向一个持久目录。 +继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill、MCP 服务器)会使子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打开一个隐式例外。仅 API key 加强制配置目录隔离使运行可复现;需要共享状态的部署可以有意将配置目录字段指向一个持久目录。 -### 为什么不为 Claude Code keyless 测试注入一个驱动 seam? +### 为什么不为 Claude Code 无密钥测试注入驱动层 seam? -注入一个假 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部的——已被 spike 消除:假 CLI harness 今天能对着真实固定版本的 SDK 工作。如果 SDK 升级破坏了 mock,keyless 套件会让升级 PR 失败,这正是门禁在发挥作用。 +注入假的 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部实现——已被 spike 消除:假 CLI harness 今天能对真实固定版本的 SDK 正常工作。如果 SDK 升级破坏了 mock,无密钥套件会让升级 PR 失败,这正是门禁在发挥作用。 ### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端? -社区 shim 将两个引擎包装为 ACP,这会让它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方第三方层,抹掉了本 RFC 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏换取第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。 +社区 shim 将两个引擎包装为 ACP,这会使它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方的第三方层,抹去了本 RFC 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏替换了第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。 ## 验收标准 -在同时配置了两个引擎和 key 的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终回答,父会话日志中仅有 `tool/call` + `tool/result`。Keyless 套件在无凭证环境中以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程 env、dispose 后无残留临时配置目录)以及 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内静默,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。 +在两个引擎和密钥均已配置的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终答案,父会话日志中仅有 `tool/call` + `tool/result`。无密钥套件在无凭证环境下以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程环境、dispose 后无残留临时配置目录),并断言 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内静默,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。 ## 风险 -- `codex app-server` 以 CLI flag 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、消费未知方法/通知而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑 keyless 套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。 -- Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过 keyless 套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生口)。 -- SDK 的 optionalDependencies 每平台约 280MB——已接受,且限制在单个后端包内。 -- SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其存在;e2e 保留无残留进程断言。 -- Codex 是部署前置条件(无 npm 捆绑的二进制文件);缺失或不兼容的二进制文件表现为大声的 spawn/协议 `error`,而非版本探测。 -- 每次运行付出一个全新子进程的代价,且仅最终回答浮出——思考、工具卡片和用量被消费后丢弃;池化、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意延后。 +- `codex app-server` 被 CLI 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、对未知方法/通知消费而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑无密钥套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。 +- Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过无密钥套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生舱口)。 +- SDK 的 optionalDependencies 每平台约 280MB——已接受,限制在单个后端包内。 +- SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其实现;e2e 保留无残留进程断言。 +- Codex 是部署前置条件(无 npm 内置二进制);缺失或不兼容的二进制以大声的 spawn/协议 `error` 呈现,而非版本探测。 +- 每次运行付出一个全新子进程的代价,且仅最终答案浮出——思考、工具卡片和用量被消费后丢弃;连接池、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意推迟。 diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml index d9853d91fe..a86bcca889 100644 --- a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-interactive-side-sessions.md: 250a906d9ec339399a0e0e29e70b2b8dc189fa72 -2026-07-08-interactive-side-sessions.zh.md: 17d416e2297320e8dfa238569230ecdec91dfa32 +2026-07-08-interactive-side-sessions.zh.md: d86a2b69232bc8ccad78555911b44cf727780e0c diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md index 17d416e229..d86a2b6923 100644 --- a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -1,41 +1,41 @@ # RFC:交互式侧会话与合并回写 -Status: proposed - [English](2026-07-08-interactive-side-sessions.md) | 中文 +Status: proposed + ## 问题 -用户可能希望在不改变当前会话主上下文的前提下探索一个问题。现有原语无法提供这种产品形态:[session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) 创建的是一个无关联的会话,而 [fork subagent](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 是模型驱动的任务,其 transcript(文本记录)会折叠为一条工具结果。两者都不能给用户一个独立的对话,也都不能将结论带着来源信息写回父会话。 +用户可能希望在不改变当前会话主上下文的前提下,探索一个来自活跃会话的问题。现有原语无法提供这种产品形态:[session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) 创建的是一个无关联的会话,而 [fork subagent](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 是模型驱动的任务,其 transcript(文本记录)会折叠为一条工具结果。两者都不能给用户一个独立的对话,也都不能将结论带着出处信息记录回父会话。 ## 提案 -**侧会话(side session)** 是一个普通的活跃会话,从源会话最后一个已完成轮次处 fork 而来,附属于自己的 agent,以只读顾问的角色运行,并能够**合并回写**一条精炼笔记。 +**侧会话(side session)** 是一个普通的活跃会话,从源会话的最后一个已完成轮次 fork 而来,绑定到自己的 agent,定位为只读顾问,并能**合并回写**一条精简笔记。 -- **Fork 并附属:** 以父会话的均衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或 session-store 方法。 -- **顾问框架:** 创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,以保留提供方对继承历史的前缀缓存。 -- **合并回写:** 向子会话请求一条有长度上限的交还内容,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求会在其日志位置看到它,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md),无需新增会话事件。 -- **呈现:** 调用方式、会话切换与交还内容的渲染属于首个客户端拥有的界面。本 RFC 仅规定与界面无关的机制。 +- **Fork 并绑定:** 以父会话的平衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或 session-store 方法。 +- **顾问定位:** 创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。 +- **合并回写:** 向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在其日志位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md),无需新增会话事件。 +- **呈现:** 调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 RFC 仅规定与界面无关的机制。 -回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据不在本 RFC 范围内。一次 live-adapter 原型验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 +回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据均不在本 RFC 范围内。一次 live-adapter spike 已验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 ## 曾考虑的替代方案 -- **使用 subagent seam:** 否决。侧会话是用户驱动的、客户端可见的,且可能比父会话的一个轮次存活更久;subagent 是模型驱动的运行,返回一条工具结果。 -- **修改子会话的系统提示词:** 默认否决,因为任何字节变化都会从第零个 token 起使前缀缓存失效。部署方仍可选择更强的隔离。 -- **新增 `sidechat/*` 事件:** 推迟。插件来源的 `context/message` 已经提供持久性、来源信息与回放能力;只有当某个界面需要区分渲染时,专用事件才有正当理由。 -- **现在就绑定协议界面:** 否决。当前 UI 由客户端拥有。实时呈现最终必须从持久化消息派生,以确保回放渲染出相同的记录。 +- **使用 subagent seam:** 否决。侧会话是用户驱动的、客户端可见的,且可能存活超过父会话的一个轮次;subagent 是模型驱动的运行,返回一条工具结果。 +- **修改子会话的系统提示词:** 默认否决,因为任何字节变化都会从第零个 token 起使前缀缓存失效。部署方仍可选择这种更强的隔离方式。 +- **新增 `sidechat/*` 事件:** 延后。插件来源的 `context/message` 已提供持久性、出处与回放能力;只有当某个界面需要差异化渲染时,专用事件才有正当理由。 +- **现在就绑定一个协议界面:** 否决。当前 UI 由客户端拥有。实时呈现最终必须从持久消息派生,以使回放渲染出相同的记录。 ## 验收标准 -- Fork 不改动源会话,并创建一个子会话,子会话具有均衡的已完成轮次前缀、`parentSession`、`seedLength`,以及逐字节一致的系统提示词。 -- 顾问框架在子会话追加历史的头部恰好添加一条插件来源的 `context/message`,而非修改其系统提示词。 +- Fork 不改变源会话,创建的子会话具有平衡的已完成轮次前缀、`parentSession`、`seedLength`,以及逐字节一致的系统提示词。 +- 顾问定位在子会话追加历史的头部恰好添加一条插件来源的 `context/message`,而非修改其系统提示词。 - 合并回写恰好添加一条有长度上限的 `context/message`,来源为 `plugin: sidechat`;父会话的下一次请求与回放在相同位置看到它。 -- 父会话与子会话并发运行,日志与流之间无串扰。 +- 父会话与子会话并发运行,日志和流之间无串扰。 - 单元测试覆盖 fork/attach 与合并回写;快照覆盖率随首个绑定界面一起落地。 ## 风险 -- 只读行为在 `tools/pre-execute` 拒绝门禁强制执行之前仅为建议性的;[拦截 seam](../../implemented/feature/2026-06-30-interception-seams.md) 可以在不改变本机制的前提下添加该门禁。 -- 经过压缩(compaction)的源会话 fork 出的是其压缩视图,因此绑定界面应当告知用户:子会话继承的是摘要而非被替换的轮次。 -- 反复的交还内容会消耗父会话上下文。每次合并的长度上限约束了单条笔记的大小;后续整合属于压缩的职责。 +- 只读行为在 `tools/pre-execute` 拒绝门禁强制执行之前仅为建议性质;[拦截 seam](../../implemented/feature/2026-06-30-interception-seams.md) 可在不改变本机制的前提下添加该门禁。 +- 经过压缩(compaction)的源会话 fork 出的是其压缩视图,因此绑定的界面应当告知用户子会话继承的是摘要而非被替换的轮次。 +- 反复的 handback 会消耗父会话上下文。每次合并的长度上限约束了单条笔记的大小;后续的合并整理属于上下文压缩的职责。 diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml index 349767710c..ab712ad131 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml @@ -3,4 +3,4 @@ # 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: 8b67baf420433feca9d5cd09d58852bb9b1545a9 -2026-07-10-sqlite-session-query-provider.zh.md: de97e59ac6f2f90a738ff5c8b9c2872d54ba3954 +2026-07-10-sqlite-session-query-provider.zh.md: ad6b44363ab54b66b597941adb13938f27971bd5 diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md index de97e59ac6..ad6b44363a 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md @@ -6,48 +6,48 @@ Status: proposed ## 问题 -精确读取服务 `ctx.sessionQuery` 有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不能在每次查询时扫描所有事件;同时,当前活跃会话需要一个比上次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤、分页、取消以及重建行为。 +精确读取的 `ctx.sessionQuery` 服务有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不是每次查询都扫描全部事件;当前的活跃会话则需要一个比上一次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤、分页、取消以及重建行为。 -如果把这些关注点拆分到一个推测性的 provider 协调器和一个数据库实现中,会产生两个耦合的协调状态机。第一个真实实现应当将源观察、提取、SQLite 事务、generation 管理和查询作为一个完整生命周期来拥有。 +如果把这些关注点拆分到一个推测性的 provider 协调器和一个数据库实现之间,会产生两个耦合的协调状态机。第一个真实实现应当将源观察、提取、SQLite 事务、generation 管理和查询作为一个完整的生命周期来拥有。 ## 提案 -在精确读取包旁新增 `@deepseek-ai/dsh-session-query-sqlite`。该包将暴露一个搜索服务或以其实际消费方所需的最小 API 扩展现有服务族;第一阶段不预先承诺 provider 注册协议。它将依赖 `ctx.sessions` 和可选的 `ctx.sessionPersistence`,拥有一个独立的派生 SQLite 数据库,并复用规范的 `foldSurface()` 分类。 +在精确读取包(exact-read package)旁新增 `@deepseek-ai/dsh-session-query-sqlite`。该包将暴露一个搜索服务,或以其实际消费方所需的最小 API 扩展服务族;第一阶段不预先承诺 provider 注册协议。它将依赖 `ctx.sessions` 和可选的 `ctx.sessionPersistence`,拥有一个独立的派生 SQLite 数据库,并复用规范的 `foldSurface()` 分类。 -实现拥有一个串行化的协调/数据库事务状态机。一次事务观察权威的持久化元数据和活跃快照,提取语义文档,更新派生表,推进相关的游标 generation,并执行或启用相应的查询。没有第二个服务维护并行的指纹、脏标记、活跃 ID 集合或失效 generation。 +实现拥有一个串行化的协调/数据库事务状态机。一次事务观察权威的持久化元数据和活跃快照,提取语义文档,更新派生表,推进相关的游标 generation,并执行或启用对应的查询。没有第二个服务维护并行的指纹、脏标记、活跃 ID 集合或失效 generation。 -持久化文档在重启后保留。活跃覆盖层是连接局部的,为同一会话遮蔽持久化行,在活跃所有者或数据库关闭时消失。派生数据库与规范持久化分离,因此索引重置、损坏、分词器变更和 schema 变动不会危及持久的对话日志。 +持久化文档在重启后存活。活跃覆盖层是连接本地的,对同一会话的持久化行进行遮蔽,在活跃所有者或数据库关闭时消失。派生数据库与规范持久化分离,确保索引重置、损坏、分词器变更和 schema 变动不会危及持久化的对话日志。 ## 随实现确定的搜索语义 -实现必须从可执行的用例出发定义跨会话和会话内两种搜索范围。每个可搜索事件是一个文档,包含会话元数据、事件元数据、surface 分类、归一化语义文本和有界的纯文本摘要片段。会话级结果按其最强匹配事件分组;数值化的后端分数保持私有。 +实现必须从可执行的用例出发定义跨会话和会话内两种搜索范围。每个可搜索事件是一个文档,包含会话元数据、事件元数据、surface 分类、归一化的语义文本和有界的纯文本摘要片段。会话级结果按其最强匹配事件分组;数值化的后端分数保持私有。 -过滤器在排序之前编译为参数化 SQL。查询语法作为数据处理。排序包含稳定的平局字段。不透明游标绑定到归一化的请求形状和最小相关 generation;不相关的会话变更不应使会话内游标失效。取消操作必须停止调用方等待,并在运行时允许的范围内中断 SQLite 工作。 +过滤器在排序之前编译为参数化 SQL。查询语法被视为数据。排序包含稳定的平局字段。不透明游标绑定到归一化的请求形状和最小相关 generation;不相关的会话变更不应使会话内游标失效。取消操作必须停止调用方等待,并在运行时允许的范围内中断 SQLite 工作。 -分词器选择仍是一个实现实验。FTS5 trigram 支持子串召回,但会拒绝短于三字符的有用词项并增大索引体积;提案在将其纳入契约之前,必须对比默认 Unicode 分词器做基准测试。 +分词器选择仍是实现层面的实验。FTS5 trigram 支持子串召回,但会拒绝短于三个字符的有用词项并增大索引体积;提案在将其写入契约之前,必须对该权衡与默认 Unicode 分词器进行基准测试。 ## 提取与协调 -该包首先为消息、推理(reasoning)、工具调用/结果、被拦截的提示词、上下文、steering(中途引导)、待办事项和错误/状态详情提供第一方语义提取。结构性事件和流式分片不贡献文档。未知的声明合并事件/内容类型保持不可搜索,除非有真实的扩展消费方证明需要公开的提取器注册表。 +该包首先为以下内容提供第一方语义提取:消息、reasoning、工具调用/结果、被阻止的提示词、上下文、steering(中途引导)、待办事项和错误/状态详情。结构性事件和流式分片不贡献文档。未知的声明合并事件/内容类型保持不可搜索,除非有真实的扩展消费方证明需要公开的提取器注册表。 -协调可以使用稳定指纹来避免重写未变更的持久化会话,但指纹的计算和存储由数据库包拥有。当源观察或提取失败时,它绝不能报告某行为最新。provider-schema 不匹配只重置派生数据库;普通的源变更使用事务性 upsert/delete。已挂载但不可读的持久化使受影响的搜索失败,但不影响规范写入或已知的活跃精确读取。 +协调可以使用稳定指纹来避免重写未变更的持久化会话,但数据库包拥有指纹的计算和存储。当源观察或提取失败时,它绝不能报告某行为最新。provider-schema 不匹配只重置派生数据库;普通的源变更使用事务性 upsert/delete。已挂载但不可读的持久化层使受影响的搜索失败,但不影响规范写入或已知的活跃精确读取。 ## 曾考虑的替代方案 -- **在规范持久化数据库中添加 FTS 表**:否决。可重建的索引不应与权威日志共享 schema/重置/故障边界。 -- **在第一阶段重新引入 provider 协调**:否决。只有一个计划中的实现,没有证据表明存在稳定的多 provider seam。 -- **立即持久化活跃覆盖层**:否决。活跃事件在现有检查点提交之前不是规范的。 -- **返回 BM25 分数**:否决。提供方特有的数值尺度在语料变化时不稳定。 +- **将 FTS 表添加到规范持久化数据库中**:否决,因为可重建的索引不应与权威日志共享 schema/重置/故障边界。 +- **重新引入第一阶段的 provider 协调**:否决,因为只有一个计划中的实现,且没有证据表明存在稳定的多 provider seam。 +- **立即持久化活跃覆盖层**:否决,因为活跃事件在现有检查点提交之前不是规范的。 +- **返回 BM25 分数**:否决,因为 provider 特定的数值尺度在语料变化时不稳定。 ## 验收标准 -- 重启测试覆盖未变更、新增、已变更和已删除的持久化会话,且不重建整个索引。 -- 重新打开时保留持久化行并移除活跃行;活跃行先遮蔽、后显露其持久化基底。 +- 重启测试覆盖未变更、新增、变更和删除的持久化会话,且不重建整个索引。 +- 重新打开时保留持久化行并移除活跃行;活跃行先遮蔽、后显露其持久化基础。 - 测试覆盖两种搜索范围、元数据过滤、surface 默认值、摘要片段、转义、确定性平局、分页、范围内的陈旧游标、取消、动态持久化挂载/卸载,以及事务失败后的恢复。 - schema 不匹配只重置派生数据库。 -- 一个无 key 的端到端测试将真实的持久化后端与真实的 SQLite 搜索包组合使用。 -- 在移入 `implemented/` 之前,本 RFC 须修订为实际实现的分词器和公开 API。 +- 一个 keyless 的端到端测试将真实的持久化后端与真实的 SQLite 搜索包组合使用。 +- 在移至 `implemented/` 之前,本 RFC 须修订为实际实现的分词器和公开 API。 ## 风险 -单一所有者比提供方无关的 seam 更简单,但初期可复用性较低。这是有意为之:第二个真实后端能揭示应当抽取什么。SQLite 运行时差异可能影响 FTS 排序和摘要片段,因此测试只能固定契约控制的排序和呈现。独立数据库增加了配置和生命周期工作,但保全了规范存储的安全边界。 +单一所有者比提供方无关的 seam 更简单,但初期可复用性较低。这是有意为之:第二个真实后端可以揭示应当抽取什么。SQLite 运行时差异可能影响 FTS 排序和摘要片段,因此测试只能固定契约控制的排序和呈现。独立数据库增加了配置和生命周期工作,但保全了规范存储的安全边界。 diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml index 6b40707fbf..a90fafdf8b 100644 --- a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-13-stream-workflow-progress-through-tool-calls.md: 525f2793052a80d82de29d2d370cfd747d002af6 -2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: b5cc86df3ca42e513bda7e56b485a8287f275613 +2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: 8dcb4aea2de50cdd278c702c9f6c85ab66e6be34 diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md index b5cc86df3c..8dcb4aea2d 100644 --- a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md @@ -6,38 +6,38 @@ Status: proposed ## 问题 -工作流引擎有意为 run、phase、narration 和子 agent 进度发出成对平衡的 `workflow/*` 观察事件,但目前没有生产消费方呈现它们。因此编辑器在最终结果到来之前只显示一张 pending 状态的工作流工具卡片,尽管引擎已经报告了当前活跃的 phase、脚本日志内容以及哪些子 agent 已启动或已完成。[dynamic-workflows 决策](../../implemented/feature/2026-07-05-dynamic-workflows.md)明确将 ACP 进度 UI 保留给这条事件流。 +工作流引擎有意为 run、phase、narration 和子 agent(智能体)进度发出成对的 `workflow/*` observation 事件,但目前没有生产消费方呈现这些事件。因此,编辑器在最终结果返回之前只显示一张 pending 状态的工作流工具卡片,尽管引擎已经报告了当前活跃的 phase、脚本日志内容以及哪些子 agent 已启动或已结束。[dynamic-workflows 决策](../../implemented/feature/2026-07-05-dynamic-workflows.md)明确将 ACP(Agent Client Protocol)进度 UI 保留给这一事件流。 -如果让 `dsh-acp` 直接监听工作流事件,会反转能力边界:通用的 UI 桥接层将依赖一个可选的工作流包(package),并对一个工具名做特殊处理。工具流水线已经拥有实时更新所需的路由信息(agent 和 call id),但只暴露了纯粹的 pending/final 展示器,因此长时间运行的工具没有提供方无关的方式在二者之间报告瞬态 UI 状态。 +如果让 `dsh-acp` 直接监听工作流事件,就会反转能力边界:通用的 UI 桥接层将依赖一个可选的工作流包(package),并对一个工具名做特殊处理。工具流水线已经拥有实时更新所需的路由信息(agent 和 call id),但只暴露了纯粹的 pending/final 展示器,因此长时间运行的工具没有提供方无关的方式在二者之间报告瞬态 UI 状态。 ## 提案 -为 `dsh-tools` 添加一条实时进度通道。注册表持有的 `ToolExecution` 新增 `reportProgress(view): boolean`,其中 `view` 是一个独立的、提供方无关的通用进度快照,包含可选的替换标题和面向 UI 的内容块。进度不能改变调用的 args 派生卡片标签、kind、原始输入、locations、terminal intent 或 diff intent;它只更新初始选定的展示形式中的实时标题/内容。执行活跃期间,该方法校验并快照 view,然后派发一个受限的、agent 作用域的 `tools/progress` 观察事件,携带权威的执行标识与快照。一旦 final-result 处理开始,方法返回 `false` 且不再派发,确保迟到的异步报告者无法覆盖终态卡片。观察者异常被记录但不会导致工具失败。 +为 `dsh-tools` 添加一条实时进度通道。注册表所有的 `ToolExecution` 新增 `reportProgress(view): boolean`,其中 `view` 是一个独立的、提供方无关的通用进度快照,包含可选的替换标题和面向 UI 的内容块。进度不能更改调用的 args 派生卡片标签、kind、原始输入、locations、terminal intent 或 diff intent;它只更新在最初选定的展示方式内的实时标题/内容。当执行处于活跃状态时,该方法校验并快照 view,然后分发一个受限的、agent 作用域的 `tools/progress` observation,携带权威的执行标识与快照。一旦 final-result 处理开始,方法返回 `false` 且不再分发,因此迟到的异步报告者无法覆盖终态卡片。观察者异常会被记录日志,不会导致工具失败。 -`dsh-acp` 以通用方式消费 `tools/progress`。它通过现有的 agent-to-session 映射解析执行所属的 agent,并为同一 call id 发出一条 in-progress 的 `tool_call_update`。由于报告仅在工具执行流水线内部可用,持久化的 `tool/call` 及其 ACP `tool_call` 始终先于第一条 update;在 `tools/result` 之前关闭报告者确保没有进度更新出现在 completed/failed 卡片之后。进度是实时 UI 状态而非模型输入或持久历史:会话回放继续从 `tool/call` 和 `tool/result` 重建 pending 与 final 卡片,无需重放瞬态更新。 +`dsh-acp` 以通用方式消费 `tools/progress`。它通过既有的 agent-to-session 映射解析执行所属的 agent,并为同一 call id 发出 in-progress 的 `tool_call_update`。由于报告仅在工具执行流水线内可用,持久化的 `tool/call` 及其 ACP `tool_call` 始终先于第一条 update;在 `tools/result` 之前关闭报告者,确保进度更新不会出现在 completed/failed 卡片之后。进度是实时 UI 状态,而非模型输入或持久历史:会话回放继续从 `tool/call` 和 `tool/result` 重建 pending 与 final 卡片,无需重放瞬态更新。 -`dsh-tool-workflow` 成为第一个生产者。每次工具执行在调用 `ctx.workflows.start()` 之前安装一个紧凑的事件捕获器,因为合法的引擎可能在 `start()` 内部同步发出进度。在调用返回之前,捕获器将观察到的事件按 `WorkflowRunInfo.id` 归约为候选状态;随后选取返回的 `WorkflowRun.id`、丢弃其他候选、报告累积的快照,并将后续匹配事件直接路由。如果 `start()` 抛出异常,捕获器被 dispose,其候选被丢弃。这在不向 `WorkflowStartRequest` 添加观察者关联、也不要求进度等到 `start()` 返回的前提下,保持了引擎的可替换性。 +`dsh-tool-workflow` 成为第一个生产者。每次工具执行在调用 `ctx.workflows.start()` 之前安装一个紧凑的事件捕获器,因为合法的引擎可能在 `start()` 内部同步发出进度。在调用返回之前,捕获器将观察到的事件按 `WorkflowRunInfo.id` 归约为候选状态;随后选取返回的 `WorkflowRun.id`,丢弃其他候选,报告累积的快照,并将后续匹配事件直接路由。如果 `start()` 抛出异常,捕获器被 dispose(资源释放),其候选状态被丢弃。这在不向 `WorkflowStartRequest` 添加观察者关联、也不要求进度等到 `start()` 返回的前提下,保持了引擎的可替换性。 -归约器消费现有的 start、phase、log、agent-start、agent-end 和 end 事件,报告一个替换快照,包含当前 phase、最新日志行、活跃子 agent 标签,以及 completed/failed/cancelled 计数。它不累积 narration transcript;已完成的子 agent 离开活跃集合、转为计数。`workflow/end`、工具结算或插件 dispose 移除归约器条目和事件捕获器。六种工作流事件、它们的元数据、成对的子 agent 生命周期、run handle、取消通道和观察者隔离保持不变;第三方观察者可继续直接消费它们。 +归约器消费既有的 start、phase、log、agent-start、agent-end 和 end 事件,报告一个替换快照,包含当前 phase、最新日志行、活跃子 agent 标签以及 completed/failed/cancelled 计数。它不累积 narration transcript(文本记录);已结束的子 agent 离开活跃集合,变为计数器。`workflow/end`、工具结算或插件 dispose 移除归约器条目和事件捕获器。六种工作流事件及其元数据、成对的子 agent 生命周期、run handle、取消通道和观察者隔离保持不变;第三方观察者可继续直接消费这些事件。 -更新工具执行/展示文档、生成的事件与 API 目录、工作流包文档以及工作流数据结构目录。ACP 集成覆盖率必须使用脚本化的模型边界对真实的工作流工具和 worker seam 进行测试;主 ACP 快照套件新增一个 workflow-progress 场景,因为此变更改变了面向编辑器的 transcript。 +更新工具执行/展示文档、生成的事件与 API 目录、工作流包文档以及工作流数据结构目录。ACP 集成覆盖率必须使用脚本化的模型边界测试真实的工作流工具和 worker seam;主 ACP 快照套件新增一个 workflow-progress 场景,因为这改变了面向编辑器的 transcript。 ## 曾考虑的替代方案 -**删除工作流观察面。** 在 [collapse-workflow 简化提案](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md)中被否决:这些事件及其平衡的生命周期是有意设计的,缺失的部分是消费方。 +**删除工作流 observation 表面。** 在 [collapse-workflow 简化提案](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md)中被否决:这些事件及其成对生命周期是有意设计的,缺少的是消费方。 -**让 ACP 直接了解工作流。** 这可以将 `WorkflowRunInfo` 映射到会话和卡片,但会使通用桥接层依赖一个可选能力,并绕过「工具拥有展示意图」的规则。工具进度通道为所有长时间运行的工具解决了同样的路由问题。 +**让 ACP 直接了解工作流。** 这可以将 `WorkflowRunInfo` 映射到会话和卡片,但会使通用桥接层依赖一个可选能力,并绕过「工具拥有展示意图」的规则。工具进度通道为每个长时间运行的工具解决了相同的路由问题。 -**将每次进度更新持久化为会话事件。** 这会使实时 narration 可回放,但会用一种权威持久结果已由 tool call/result 对表达的状态永久膨胀日志。如果可恢复的工作流进度成为产品需求,它需要一个工作流日志化设计,而非伪装成持久事实的 UI 快照。 +**将每条进度更新持久化为会话事件。** 这会使实时 narration 可回放,但会用一种状态永久膨胀日志,而该状态的权威持久结果已经是工具调用/结果对。如果可恢复的工作流进度成为产品需求,需要一个工作流日志化设计,而非伪装成持久事实的 UI 快照。 ## 验收标准 -- `ToolExecution.reportProgress()` 由注册表持有、agent 作用域、快照化、观察者隔离,且在终态处理开始后返回 `false` 而不派发。 -- ACP 将进度路由到正确实时会话中的正确调用;不同会话中的并发工作流不能串扰,且 `tool_call_update` 不会出现在其 `tool_call` 之前或终态更新之后。 -- 工作流进度显示当前 phase、最新日志行、活跃子 agent 和结果计数,同时保持所有现有 `workflow/*` 事件和 run 语义;一个在 `start()` 内部同步发出 start、phase、log、child 和 end 事件的 seam 测试引擎不会丢失任何归约器状态。 +- `ToolExecution.reportProgress()` 由注册表所有、agent 作用域、快照化、观察者隔离,且在终态处理开始后返回 `false` 而不分发。 +- ACP 将进度路由到正确的实时会话中的正确调用;不同会话中的并发工作流不能串扰,且 `tool_call_update` 不会出现在其 `tool_call` 之前或终态更新之后。 +- 工作流进度显示当前 phase、最新日志行、活跃子 agent 和结果计数,同时保留所有既有 `workflow/*` 事件和 run 语义;一个在 `start()` 内部同步发出 start、phase、log、child 和 end 事件的 seam 测试引擎不会丢失任何归约器状态。 - 取消、worker 死亡、工具失败、会话关闭和插件 dispose 释放归约器状态;回放仅发出持久的 pending/final 卡片对。 - 单元测试、工作流集成测试、ACP 集成测试、快照、类型检查、覆盖率、doc-sync、module-graph、构建和 hygiene 门禁全部通过。 ## 风险 -此变更向工具 seam 添加了一个公开的实时进度方法和事件,因此实现方必须精确维护 active/terminal 边界,并在观察者看到快照之前将其分离。pre-start 捕获器可能短暂观察到无关的工作流 run,因此它仅按 run id 持有紧凑的候选状态,并在 `start()` 返回后立即丢弃所有不匹配的候选。一个工作流可能发出大量进度变更;有界归约器避免了 transcript 增长,但在关联之后仍会为每个有意义的事件发送一次 UI 更新。如果实测客户端需要合并更新,必须通过带默认值的、经过校验的桥接配置实现,而非硬编码的节流。瞬态进度在回放时有意消失,因此最终的工具结果仍是唯一持久的工作流卡片内容。 +本提案向工具 seam 添加了一个公开的实时进度方法和事件,因此实现方必须精确维护 active/terminal 边界,并在观察者看到快照之前将其分离。pre-start 捕获器可能短暂观察到无关的工作流 run,因此它仅按 run id 持有紧凑的候选状态,并在 `start()` 返回后立即丢弃所有不匹配的候选。一个工作流可能发出大量进度变更;有界归约器避免了 transcript 增长,但在关联完成后仍会为每个有意义的事件发送一条 UI 更新。如果经测量的客户端需要合并更新,这必须是一个带默认值的、经过校验的桥接配置,而非硬编码的节流。瞬态进度在回放时有意消失,因此最终工具结果仍是唯一持久的工作流卡片内容。 diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml index 26c31b7471..fc5effd89a 100644 --- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-api-extractor-reports.md: 0f3f736ba662fd6366eb8d7f26887fb319b2b563 -2026-06-11-api-extractor-reports.zh.md: 3c472d64bc96c7fffa784c091f8a7a70bb0beb56 +2026-06-11-api-extractor-reports.zh.md: cf0eb3f9edbcfb2ae862f075af0628e716693a86 diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md index 3c472d64bc..cf0eb3f9ed 100644 --- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md @@ -4,29 +4,29 @@ Status: proposed -> 从最初的「Doc-sync 与 API 报告」RFC(2026-06-11)中拆出。第 1、2 部分(文档块类型检查、事件分类体系校验)已交付——见 [doc-sync 强制](../../implemented/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 +> 从最初的「Doc-sync 与 API 报告」RFC(2026-06-11)中拆出。第 1–2 部分(文档块类型检查、事件分类体系校验)已交付,见 [doc-sync 强制](../../implemented/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 ## 问题 -公开 API 的变更是不可见的:没有任何机制让「这个 commit 改变了公开接口」成为一个显式、可评审的事实。评审者阅读 diff 时可能遗漏一个导出类型新增了字段或方法签名发生了变化。 +公开 API 的变更是不可见的:没有任何机制将「此次提交改变了公开接口」变为一个显式、可评审的事实。评审者阅读 diff 时可能遗漏某个导出类型新增了字段,或某个方法签名发生了变化。 ## 提案 -使用 api-extractor(或 `tsc --emitDeclarationOnly` 加一份归一化的公开接口导出)为每个包(package)生成一份签入仓库的 `etc/<pkg>.api.md`;如果重新生成的结果与签入版本不同,CI 失败。这样每一次公开 API 变更都会变成评审者(或评审 agent)必须看到的一行 diff。 +使用 api-extractor(或 `tsc --emitDeclarationOnly` 加一份规范化的公开接口导出)为每个包(package)生成一份签入仓库的 `etc/<pkg>.api.md`;CI 在重新生成结果与已签入报告不一致时失败。这样,每一次公开 API 变更都会成为评审者(或评审 agent(智能体))必须看到的一行 diff。 ## 曾考虑的替代方案 -**`tsc --emitDeclarationOnly` 加一份归一化的公开接口导出**:如果 api-extractor 被证明过重,这是更轻量的机制;两者都满足本提案所需的「签入仓库、可 diff」的报告形态。 +**`tsc --emitDeclarationOnly` 加规范化的公开接口导出**:如果 api-extractor 过于笨重,这是更轻量的机制;两者都能满足提案所需的「签入仓库、可 diff」的报告形态。 ## 验收标准 -- 每个包有一份签入仓库的 `etc/<pkg>.api.md`;重新生成结果与已提交报告不同时 CI 失败。 +- 每个包都有一份签入仓库的 `etc/<pkg>.api.md`;CI 在重新生成结果与已提交报告不一致时失败。 - 公开 API 变更(新增导出、字段放宽、签名变化)在评审中以报告 diff 行的形式可见。 ## 风险 -该依赖重且难伺候——这正是它被推迟的原因——且报告格式会随编译器升级而变动,在各包尚未发布的阶段增加了一个收益甚微的维护面。 +该依赖笨重且难以调教(这正是它被推迟的原因),且报告格式会随编译器升级而变动,增加一个维护面;在各包尚未发布的阶段,收益有限。 ## 推迟原因 -在 doc-sync 落地时被推迟:对于评审者已经能看到源码 diff 的内部 monorepo 而言价值有限,且依赖重、难伺候。如果这些包将来对外发布,届时一份稳定、可 diff 的公开接口报告才值得其维护成本。 +在 doc-sync 落地时被推迟:对于一个内部 monorepo,评审者已经能看到源码 diff,价值不高;且依赖笨重、难以调教。如果各包将来对外发布,再重新评估——届时一份稳定、可 diff 的公开接口报告才值得其维护成本。 diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml index 8aa3f297cd..094c2c349b 100644 --- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-architectural-conformance.md: 40858d049af2df1928e27280238d0b198a5202f7 -2026-06-11-architectural-conformance.zh.md: d61751210ef78b26e05a05efae4d5abccbfd2e5c +2026-06-11-architectural-conformance.zh.md: b68355dc1c04a4f807efdb95f813159cb7f9f178 diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md index d61751210e..b68355dc1c 100644 --- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md @@ -6,31 +6,31 @@ Status: proposed ## 问题 -两项架构保证目前仅存在于行文中:(1)任何包不得依赖具体的 loop 包([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确地遵循 chunk 协议。两者都应当机械化([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 +目前有两项架构保证仅存在于行文中:(1)没有任何东西依赖具体的 loop 包([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确地遵循 chunk 协议。二者都应当是机械化的([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 ## 提案 **dependency-cruiser** 配合以下规则: -- `packages/*`(agent-loop 自身的测试和 examples/ 除外)禁止导入 `@deepseek-ai/dsh-agent-loop`。 +- `packages/*`(除 agent-loop 自身的 tests 和 examples/ 外)禁止导入 `@deepseek-ai/dsh-agent-loop`。 - 禁止跨包深层导入(`@deepseek-ai/dsh-*/src/...` 路径)——只允许使用公开入口点。 -- packages/ 内禁止任何导入循环。 +- packages/ 内禁止导入循环。 - `vendor/*` 禁止从 `packages/*` 导入。 -- 分层:dsh-llm 不导入其他 dsh 包;dsh-session 只导入 dsh-llm;以此类推(即 packages/README.md 中的依赖表,强制执行)。 +- 分层:dsh-llm 不导入其他 dsh 包;dsh-session 仅导入 dsh-llm;以此类推(packages/README.md 中的依赖表,强制执行)。 -**适配器一致性套件**位于 dsh-llm(`@deepseek-ai/dsh-llm/conformance`):一个可复用的 vitest 套件,以适配器工厂为参数,断言 chunk 协议契约——每个 block 的 index 单调递增、`block-end` 之后该 index 不再有 delta、恰好一个 `finish`、usage 至多出现一次、每个 `tool-call-delta` 携带 call id、abort 被及时响应。当前对 mock 运行;DeepSeek V4 适配器从第一天起继承该套件。可选地提供一个 dev 模式的 `strictAdapter()` 包装层,在 debug flag 下于运行时强制执行相同约束(与 [dev 模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)配对)。 +**适配器一致性套件**位于 dsh-llm(`@deepseek-ai/dsh-llm/conformance`):一个可复用的 vitest 套件,以适配器工厂为参数,断言 chunk 协议契约——每个 block 内 index 单调递增、`block-end` 之后该 index 不再有 delta、恰好一个 `finish`、usage 至多出现一次、每个 `tool-call-delta` 携带 call id、abort 被及时响应。当前对 mock 运行;DeepSeek V4 适配器从第一天起继承该套件。可选地提供一个 dev 模式的 `strictAdapter()` 包装层,在 debug flag 下于运行时强制执行相同规则(与 [dev 模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) 配对)。 ## 计划 -先落地 dependency-cruiser 配置与 CI 步骤(约一小时工作量,永久保证);一致性套件随其首个消费方测试(针对 MockAdapter)一起落地,并作为 V4 适配器阶段的前置条件。 +先落地 dependency-cruiser 配置与 CI 步骤(约一小时工作量,换来永久保证);一致性套件随其首个消费方测试(针对 MockAdapter)一起落地,并作为 V4 适配器阶段的前置条件。 ## 验收标准 - dependency-cruiser 在 CI 中运行上述规则族;违规导入导致构建失败。 -- 一致性套件对 mock 适配器和两个正式适配器运行通过;新适配器包通过调用该套件并传入自己的工厂即可继承测试。 +- 一致性套件对 mock 适配器和两个正式适配器运行,新适配器包通过调用该套件并传入自己的工厂即可继承测试。 ## 风险 -随着包的增加需要维护 dep-cruiser 规则——应保持规则基于模式(`dsh-*`)而非逐一枚举。 +随着包的增加,dep-cruiser 规则需要维护——规则应基于模式(`dsh-*`)而非逐一枚举。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml index 5ac9f62fee..62e4b4aa6f 100644 --- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-supply-chain-and-vendor-drift.md: 306e185e9175e3e7af24455cf95167f54b3d1c17 -2026-06-11-supply-chain-and-vendor-drift.zh.md: 1aeb0a8eff3f335bc87c05742acc4502a19bf3c5 +2026-06-11-supply-chain-and-vendor-drift.zh.md: a840e766c49182d7a9ca648acbbab1a2762688f5 diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md index 1aeb0a8eff..a840e766c4 100644 --- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md @@ -1,4 +1,4 @@ -# RFC:供应链检查与 vendor 漂移校验 +# RFC:供应链检查与 vendor 漂移验证 [English](2026-06-11-supply-chain-and-vendor-drift.md) | 中文 @@ -6,30 +6,30 @@ Status: proposed ## 问题 -vendor manifest([vendor 化决策](../../implemented/process/2026-06-11-vendor-cordis-as-source.md))在提交时只做*正向*强制(vendor 代码变更 ⇒ manifest 更新),但没有任何机制校验 manifest 的*声明*:即 vendor/ 确实等于「上游指定 SHA 的代码 + 日志中记录的修改」。此外,少量真正的 npm 依赖也没有安全公告监控或更新节奏。 +vendor manifest(元数据清单)(见[引入 vendor 的决策](../../implemented/process/2026-06-11-vendor-cordis-as-source.md))在提交时仅在**正向**强制执行(vendor 变更 ⇒ manifest 更新),但没有任何机制验证 manifest 的**声明**:即 vendor/ 确实等于上游指定 SHA 的内容加上所记录的修改。此外,少量真正的 npm 依赖也没有安全公告监控或更新节奏。 ## 提案 -1. **Vendor 漂移检查**(夜间 CI):以 manifest 中的 SHA 浅克隆上游仓库,复制对应 package 的源码,与 `vendor/*/src` 做 diff。除非 diff 与日志中的本地修改一致(每项修改保存为一个入库的 patch 文件,使日志条目成为可校验的产物而非纯文字),否则 job 失败。 -2. **依赖安全公告**:对 lockfile 运行 osv-scanner(或 `pnpm audit`),按计划调度 + 在涉及 lockfile 的 PR 上触发。 -3. **许可证清单**:一个脚本断言每个 vendor 化的 package 都携带 LICENSE 文件,且 package.json 的 `license` 字段与 vendor/README.md 中的清单一致(我们混合了 vendor 化的 MIT 与自有的 BSD-3)。作为 CI 步骤运行。 -4. **Renovate**(或一个定时 agent 任务)以小 PR 提议 npm 依赖更新,这些 PR 走完整门禁套件;vendor 化的 package 排除在外(它们的更新遵循 manifest 同步流程,理想情况下作为半自动化的 agent 工作流:拉取上游、重新应用 patch、运行门禁、打开 PR 并更新 manifest 表格)。 +1. **Vendor 漂移检查**(夜间 CI):以 manifest 中记录的 SHA 浅克隆上游仓库,复制对应的 package 源码,与 `vendor/*/src` 做 diff。除非 diff 与已记录的本地修改一致(每项修改以签入的 patch 文件保存——日志条目从行文描述变为可验证的产物),否则任务失败。 +2. **依赖安全公告**:对 lockfile 运行 osv-scanner(或 `pnpm audit`),按计划定期执行,并在涉及 lockfile 变更的 PR 上触发。 +3. **许可证清单**:一个脚本断言每个 vendor 包都携带其 LICENSE 文件,且 package.json 的 `license` 字段与 vendor/README.md 中的清单一致(我们混合了 vendor 的 MIT 与自有的 BSD-3)——作为 CI 步骤运行。 +4. **Renovate**(或定时 agent 任务)以小 PR 的形式提议 npm 依赖更新,这些 PR 走完整门禁套件;vendor 包不在其列(它们的更新遵循 manifest 同步流程,理想情况下是半自动化的 agent 工作流:拉取上游、重新应用 patch、运行门禁、以更新后的 manifest 表格开 PR)。 ## 计划 -3 最简单,先做。1 需要 CI 能通过网络访问上游仓库(私有镜像,需要 token),并将现有两项已记录的修改转为 patch 文件。2 和 4 属于配置工作。 +第 3 项最简单,先做。第 1 项需要 CI 能通过网络访问上游仓库(私有仓库,需要 token),并将现有两项已记录的修改转换为 patch 文件。第 2 项和第 4 项是配置工作。 ## 曾考虑的替代方案 -- **用 `pnpm audit` 代替 osv-scanner**:两者都满足安全公告扫描的需求;具体选择推迟到实现阶段决定。 -- **用定时 agent 任务代替 Renovate**:在「以小 PR 提议更新并走完整门禁」这件事上效果等价;vendor 化的 package 无论哪种方案都排除在外(它们的更新遵循 manifest 同步流程)。 +- **用 `pnpm audit` 替代 osv-scanner**:两者都满足安全公告扫描的需求;具体选择推迟到实现阶段决定。 +- **用定时 agent 任务替代 Renovate**:在提议小型更新 PR 并走完整门禁套件方面效果等价;vendor 包无论哪种方案都不在其列(它们的更新遵循 manifest 同步流程)。 ## 验收标准 -- 许可证清单脚本在 CI 中运行,缺少 LICENSE 或 `license` 字段与 `vendor/README.md` 清单矛盾时失败。 -- 夜间漂移 job 从 manifest SHA 加入库 patch 文件重建 `vendor/`,出现任何无法解释的 diff 时失败。 -- 安全公告扫描按计划对 lockfile 运行,并在涉及 lockfile 的 PR 上运行。 +- 许可证清单脚本在 CI 中运行,缺少 LICENSE 或 `license` 字段与 `vendor/README.md` 中的清单矛盾时失败。 +- 夜间漂移任务从 manifest SHA 加签入的 patch 文件重建 `vendor/`,出现任何无法解释的 diff 时失败。 +- 安全公告扫描按计划定期运行,并在涉及 lockfile 变更的 PR 上运行。 ## 风险 -上游仓库是私有镜像;CI 凭证与可用性是漂移检查的主要阻力。如果受阻,改为本地定时 agent 任务而非 CI 运行。 +上游仓库是私有镜像;CI 凭证与可用性是漂移检查的主要阻力。如果受阻,可改为本地定时 agent 任务而非 CI。 diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml index f08e3c5dff..ddaafef6da 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-discover-package-inventory.md: 22b3e9acbe4dad8ef829d0dd30415c516031d66b -2026-06-20-discover-package-inventory.zh.md: 8b62d42d2d514f60016690ba55d5ce77a50b3aff +2026-06-20-discover-package-inventory.zh.md: 4eeaed9ed6b390608095281775883f8e7a52e954 diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md index 8b62d42d2d..4eeaed9ed6 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md @@ -1,36 +1,36 @@ -# RFC:通过发现机制获取包清单,取代静态列表维护 - -[English](2026-06-20-discover-package-inventory.md) | 中文 +# RFC:通过发现机制获取包清单,而非维护静态列表 Status: proposed +[English](2026-06-20-discover-package-inventory.md) | 中文 + ## 问题 -包(package)与门禁的清单在 TypeScript project references、package 文档、CI 行文、Knip 覆盖项以及快照场景元数据中反复出现。其中大部分只是重述包布局、manifest 数据、聚合命令内容或 fixture(测试前置数据)文件。每新增一个包或场景,都会产生本可避免的同步点。 +包(package)与门禁清单在 TypeScript project references、包文档、CI 描述、Knip 覆盖项以及快照场景元数据中反复出现。大多数只是重述包布局、manifest 数据、聚合命令内容或 fixture(测试前置数据)文件。因此每新增一个包或场景都会产生本可避免的同步点。 -[包层级结构](../../implemented/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages/<group>/<pkg>` 布局推导清单,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单——主要是 `tsconfig.build.json` 的 project `references`,TypeScript 要求它是一个显式数组(没有通配符形式)。 +[包层级结构](../../implemented/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages/<group>/<pkg>` 布局推导列表,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单,主要是 `tsconfig.build.json` 的 project `references`——TypeScript 要求它是显式数组(没有通配符形式)。 -静态列表在编码策略时是合理的;当它们只是重复 `package.json`、workspace glob 或包层级结构中已有的 manifest 数据或布局事实时,就是无谓的摩擦。 +当静态列表编码的是策略时,它们是合理的;当它们只是重复 `package.json`、workspace glob 或包层级结构中已有的 manifest 数据或布局事实时,就是不必要的摩擦。 ## 提案 -让剩余的包/门禁清单可被发现。一个唯一的权威来源——`packages/<group>/<pkg>` 层级结构加上 package manifest——应当驱动 `tsconfig.build.json` 的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写入产物,`--check` 模式在 `hygiene`/`doc-sync` 中检测已提交副本是否陈旧)。模块图生成器已经在读取 package manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令而非重述第二份清单。 +让剩余的包/门禁清单可被发现。一个唯一的权威来源——`packages/<group>/<pkg>` 层级结构加上包 manifest(元数据清单)——应当驱动 `tsconfig.build.json` 的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写出产物,`hygiene`/doc-sync(文档同步门禁)中的 `--check` 模式在提交副本陈旧时报错)。模块图生成已经在读取包 manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令而非重述第二份列表。 -层级结构不需要编码一个包的所有信息,但应当编码宽泛的维护策略:core/product 包、集成包、能力 seam 包与 support/test/example 包不应在脚本能区分它们之前先要求一份手工维护的例外清单。 +层级结构不需要编码关于包的所有事实,但应当编码宽泛的维护策略:core/product 包、集成包、能力 seam 包与 support/test/example 包不应在脚本能区分它们之前先要求一份手工维护的例外列表。 -有两项被编目的内容根本不需要生成器:把 e2e 入口 glob 折入 knip 的默认 stanza 即可直接删除各包的重述;`childSessions` 可以从每个场景的 fixture 目录发现,让场景表只声明策略(`recorded`、`hasModelTurn`、`comparesLog`)。而即便这些策略字段,今天也在追踪可从 fixture 推导的事实(`comparesLog` ⟺ 已提交的日志在表头行之后有内容;`recorded` ⟺ `hasModelTurn` 且没有 `replay.override.json` 兄弟文件),因此每个新场景类别都在不断添加 fixture 目录已经能回答的开关。 +有两类编目项根本不需要生成器:将 e2e 入口 glob 折入 knip 的默认配置段即可直接删除逐包的重复声明;`childSessions` 可从每个场景的 fixture 目录发现,使场景表只需声明策略(`recorded`、`hasModelTurn`、`comparesLog`)。而且即便是这些策略字段,今天也在追踪可从 fixture 推导的事实(`comparesLog` ⟺ 已提交的日志在头行之后还有条目;`recorded` ⟺ `hasModelTurn` 且没有 `replay.override.json` 兄弟文件),因此每个新场景类都在不断添加 fixture 目录本身已经能回答的开关。 ## 验收标准 -- `tsconfig.build.json` 的 project `references` 由层级结构生成(生成器输出它们;`--check` 门禁在已提交副本陈旧时失败),而非手工维护。 -- 新增一个包不需要为任何门禁编辑静态包列表。 +- `tsconfig.build.json` 的 project `references` 由层级结构生成(生成器输出它们;`--check` 门禁在提交副本陈旧时报错),而非手工维护。 +- 新增一个包时,不需要为任何门禁编辑静态包列表。 - 文档描述真源,而非重复生成的清单。 - CI 调用聚合命令,由这些命令自行管理其子门禁列表。 -- `knip.json` 仅在编码真实信息(额外入口文件、被忽略的依赖)时才携带 per-package 覆盖项,绝不重述默认 stanza。 +- `knip.json` 仅在编码真实信息(额外入口文件、被忽略的依赖)时才携带逐包覆盖项,绝不重述默认配置段。 - 快照场景只声明策略,不声明可从其 fixture 目录发现的事实。 ## 风险 -发现脚本可能变得过于精巧。实现应保持朴素:读取 manifest、按显式字段过滤、打印解析后的列表、出错时大声报错。收益在于消除手工清单漂移,而非发明一套构建系统。 +发现脚本可能变得过于精巧。实现应当保持朴素:读取 manifest、按显式字段过滤、打印解析后的列表、出错时大声报错。收益在于消除手工清单的漂移,而非发明一套构建系统。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml index db8dc2d650..dde7a0ad32 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-unify-agent-and-session-id.md: 3a6daa411673003eb1c3017e7a717ae4bf98b735 -2026-06-20-unify-agent-and-session-id.zh.md: c931831c028e3147bfb84ed4fdeefe83037e92f4 +2026-06-20-unify-agent-and-session-id.zh.md: 6b1b996879125c6ab85aed7ba419aff15d077b42 diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md index c931831c02..6b1b996879 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -6,37 +6,37 @@ Status: proposed ## 问题 -agent 工厂为每个活跃的 agent/会话对维护两个 id:`agentId`(`AgentRegistry` 的路由句柄)和 `sessionId`(事件溯源与持久化日志的身份标识)。`CreateAgentOptions` 接收两者;`ResumeAgentOptions` 接收 `agentId` 加 `resumeSessionId`;进程内 subagent 铸造两个独立的 UUID,尽管血缘关系另行记录。 +agent 工厂为每个活跃的 agent/session 对维护两个 id:`agentId`(`AgentRegistry` 的路由句柄)和 `sessionId`(事件溯源与持久化日志的标识)。`CreateAgentOptions` 接收两者;`ResumeAgentOptions` 接收 `agentId` 加 `resumeSessionId`;进程内 subagent 各自铸造两个独立的 UUID,尽管血缘关系另行记录。 -ACP(Agent Client Protocol)已经对两个身份使用同一个值。二者在配置创建的 agent、恢复的会话和进程内子 agent 中才出现分歧,但没有任何生产路径会将一个活跃 agent 重新关联到多个会话,或让一个会话经过多个 agent id。Stdio 保留 `labelBySession` 仅仅是为了从会话事件中恢复 agent 标签,而钩子同时暴露两个值让使用者自行对齐。 +ACP(Agent Client Protocol)已经对这两个标识使用同一个值。它们在配置创建的 agent(智能体)、恢复的会话和进程内子 agent 中才出现分歧,但没有任何生产路径会把一个活跃 agent 重新关联到多个会话,或让一个会话经过多个 agent id。Stdio 保留 `labelBySession` 仅仅是为了从会话事件中恢复 agent 标签,而钩子同时暴露两个值让使用者自行调和。 -[agent 作用域运行时](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)没有与身份相关的预留状态:创建和恢复使用同一个 `AgentCreationTransaction`,两个注册表条目使用相同的 final-entry 碰撞规则。分离的 id 并未复制活跃性、回滚或静默机制。统一后删除一个调用方提供的 id、每个进程内子 agent 的一个 UUID 以及剩余的翻译路径,而不改变事务生命周期;同时使活跃 agent 注册表强制执行后台任务所有权所使用的会话身份。 +[agent-scope 运行时](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)没有与标识相关的保留状态:创建和恢复使用同一个 `AgentCreationTransaction`,两个注册表条目都使用相同的 final-entry 碰撞规则。分离的 id 并不会使活跃性、回滚或静默机制产生重复。统一后删除一个调用方提供的 id、每个进程内子 agent 的一个 UUID 以及剩余的转换路径,而不改变事务生命周期;同时使活跃 agent 注册表强制执行后台任务所有权所使用的会话标识。 -`Session` 另外同时暴露 `Session.id` 和 `Session.header.id`,尽管构造时要求二者一致。持久化边界必须校验这一重复值,消费方必须在同一事实的两个归属位置之间做选择。 +`Session` 另外同时暴露 `Session.id` 和 `Session.header.id`,尽管构造时要求二者必须一致。持久化边界必须校验这个重复值,消费方必须在同一事实的两个归属位置之间做选择。 ## 提案 -对 agent 注册表条目和 `session.header.id` 使用同一个 id。`CreateAgentOptions` 为两个最终条目接受一个身份标识;恢复操作以被恢复的 session id 注册 agent;subagent 创建铸造一个合并后的 id;`Session` 只保留一个身份归属位置。保留当前的事务、final-entry 碰撞检查、exact-entry 摘除、回滚与静默机制;仅移除唯一职责是在两个 id 之间做翻译的 map 和字段。 +对 agent 注册表条目和 `session.header.id` 使用同一个 id。`CreateAgentOptions` 为两个最终条目接收一个标识;恢复操作以被恢复的 session id 注册 agent;subagent 创建铸造一个合并后的 id;`Session` 只保留一个标识归属位置。保留当前的事务、final-entry 碰撞检查、exact-entry 摘除、回滚与静默机制;仅移除唯一职责是在两个 id 之间做转换的 map 和字段。 -配置驱动的路径必须先确定其恢复还是创建的策略。目前它使用一个稳定的 agent 标签加一个带 UUID 后缀的新 session id,以避免在下次运行时与已有的持久化日志碰撞。统一后它必须明确选择:恢复一个固定 id、铸造一个新的合并 id,或将该策略暴露出来;实现不得默默做出选择。 +配置驱动的路径必须先确定其恢复还是创建的策略。当前它使用一个稳定的 agent 标签加一个带 UUID 后缀的新 session id,以避免在下次运行时与已有的持久化日志碰撞。统一后,它必须明确选择:恢复一个固定 id、铸造一个新的合并 id,还是将该策略暴露出来;实现不得默默做出选择。 -`agent/created` 和 `agent/disposed` 不在本提案范围内。它们是发布生命周期事件而非身份别名;移除它们需要单独的生产方-消费方审计与决策。 +`agent/created` 和 `agent/disposed` 不在本提案范围内。它们是发布生命周期事件而非标识别名;移除它们需要单独的生产方-消费方审计与决策。 ## 曾考虑的替代方案 -**保留分离的路由身份与日志身份。** 一个稳定的配置 agent 标签搭配一个新的对话,是这种区分的真实用途。如果确实需要该显示或路由身份,则应否决本提案,转而显式强制 session id 唯一性,而不是将翻译隐藏在另一个 map 中。 +**保留分离的路由标识与日志标识。** 一个稳定的配置 agent 标签配合一个新的对话,是这种区分的真实用途。如果确实需要该显示或路由标识,请否决本提案,转而显式强制 session id 唯一性,而不是把转换隐藏在另一个 map 中。 ## 验收标准 -- agent 创建/恢复与 subagent 创建只携带一个身份标识;`Session` 将其存储在一个位置。 -- 创建事务保留 final-entry 碰撞、exact-entry 摘除、回滚与静默保证,且不依赖与身份相关的生命周期状态。 -- ACP、stdio、钩子、bash 所有权、持久化与血缘关系无需 agent/session id 翻译。 -- 配置驱动的恢复还是创建策略是显式的,并在持久化重启场景中得到覆盖。 +- agent 创建/恢复与 subagent 创建只携带一个标识;`Session` 将其存储在一个位置。 +- 创建事务在不依赖标识相关生命周期状态的前提下,保留 final-entry 碰撞、exact-entry 摘除、回滚与静默保证。 +- ACP、stdio、钩子、bash 所有权、持久化与血缘关系无需进行 agent/session id 转换。 +- 配置驱动的恢复还是创建策略是显式的,并在持久化重启场景下得到覆盖。 - `agent/created` 和 `agent/disposed` 仅在单独的生产方-消费方审计之后才变更。 - 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 ## 风险 -统一后将无法再拥有一个跨多个会话日志的稳定 actor 身份,包括未来可能的交接或 fork(保留 actor 但更换会话)。重新引入该设计需要一个新的显式 actor 身份。统一还使一个持久化的、可能由客户端选择的 session id 成为注册表句柄,并改变每个创建/恢复调用点和 fixture(测试前置数据)。 +统一后将无法再拥有一个跨多个会话日志的稳定 actor 标识,包括未来可能出现的、在保留 actor 的同时切换会话的 handoff 或 fork 场景。重新引入该设计将需要一个新的显式 actor 标识。统一还使一个持久化的、可能由客户端选定的 session id 成为注册表句柄,并改变每个创建/恢复的调用点与 fixture(测试前置数据)。 -配置重启策略是阻塞性的设计决策:固定的合并 id 可能与其已有日志碰撞,而每次运行生成新 id 则放弃了稳定的配置标签。如果确实需要独立的 actor 身份或稳定标签/新会话的配对,则应否决本提案,保留分离的 id 并加上显式的唯一性守卫。 +配置重启策略是阻塞性的设计决策:固定的合并 id 可能与已有日志碰撞,而每次运行生成新 id 则放弃了稳定的配置标签。如果确实需要独立的 actor 标识或稳定标签/新会话的配对,请否决本提案,保留分离的 id 并加上显式的唯一性守卫。 diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 1d5522f5b3..4420b2dd27 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-dead-core-spine-surface.md: c46fe464e8627dcfc39a1d3fbb38a9cbd84269cf -2026-07-04-prune-dead-core-spine-surface.zh.md: 86830904ef0d1262d4cc132fb8d4b6a50e033e1d +2026-07-04-prune-dead-core-spine-surface.zh.md: 67e89a580b086a08aed702d9e0b87bdb6e32c944 diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 86830904ef..67e89a580b 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -1,4 +1,4 @@ -# RFC:裁剪无用的公开接口与结果面 +# RFC:裁剪无用的公开与结果接口 Status: proposed @@ -6,57 +6,57 @@ Status: proposed ## 问题 -若干包根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入内部实现,要么是因为某个类型预设了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的目录、文档和回归矩阵,却没有支撑任何已交付的路径。 +若干包根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入了内部实现,要么是因为某个类型预期了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的 catalog、文档和回归矩阵,却没有支撑任何已交付的路径。 -生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、package README 和 RFC 行文是发布的证据,但不是固定的调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此被编目的服务方法和返回形状是真正的动态产品面。下表因此区分了「没有固定的仓库内调用者」与「不可达」:涉及编目词汇的行有意收缩模型编写的 mount 所能发现和调用的内容,而包根的实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: +生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包(package) README 和 RFC 行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: -| 接口面 | 生产证据 | 简化方式 | +| 接口 | 生产证据 | 简化方式 | | --- | --- | --- | -| `SurfaceManager.invalidate()` | 仅其单元测试调用;seeding 在惰性创建的 manager 存在之前就已完成,且会话从不替换其日志引用。 | 删除该方法及其不可能触发的整体替换契约。 | -| `ToolExecutionResult.callId` | 每个钩子已经接收不可变的 `ToolExecution`;循环和 ACP(Agent Client Protocol)通过 call/session 事件关联。没有消费方读取这个重复的结果字段。 | 移除该字段、复制/不匹配守卫,以及证明该重复不会不一致的测试。 | -| `ReactLoopAgent` 根导出 | 包外的具名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 返回/接口类型为 `Agent`,将具体循环类设为包内部;保留有意为之的同步纯配置 `AgentLoop.create()` 路径。 | -| `workflow-workerthread` 的 protocol/runtime/session 再导出与具名 `WorkerWorkflowEngine` | 所有包名消费方使用默认引擎;workflow RFC 已将 worker 协议格式定义为私有。 | 保留默认插件类/配置契约;移除重复的具名类导出,将协议模块设为源码私有。 | -| `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇设为源码私有。 | -| ACP 的 translation/presenter 根导出 | `agentOptions`、`streamSessionEventUpdate`、`todosToPlan`、`ToolPresenter`、`nullToolPresenter` 和 `TerminalRendering` 仅有同文件或 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 translation/presentation 辅助函数设为源码私有,在包内测试。 | -| `providerWording` 和 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;仅 balanced-prefix 辅助函数有一个同包白盒测试。 | 设为源码私有,通过 provider 行为测试。 | -| `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 和 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/释放辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 设为源码私有;通过 spawn 和释放来测试。 | -| `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 和 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 设为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 | -| `LlmError.status` 与 replay status | 适配器/replay 填充它,但生产分支基于稳定的 error code/message,从不读取原始 status。 | 移除未读字段和 replay 管道,同时保留错误分类。 | +| `SurfaceManager.invalidate()` | 只有其单元测试调用它;seeding 在惰性创建的 manager 存在之前就已完成,且会话从不替换其日志引用。 | 删除它及其不可能触发的整体替换契约。 | +| `ToolExecutionResult.callId` | 每个钩子已经接收不可变的 `ToolExecution`;循环和 ACP(Agent Client Protocol)通过 call/session 事件关联。没有消费方读取这个重复的结果字段。 | 移除该字段、复制/不匹配守卫,以及证明该重复不可能不一致的测试。 | +| `ReactLoopAgent` 根导出 | 包外的命名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 返回/接口类型为 `Agent`,将具体循环类改为包内部;保留有意设计的同步、仅配置的 `AgentLoop.create()` 路径。 | +| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 每个包名消费方都使用默认引擎;workflow RFC 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 | +| `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 | +| ACP 的 translation/presenter 根导出 | `agentOptions`、`streamSessionEventUpdate`、`todosToPlan`、`ToolPresenter`、`nullToolPresenter` 和 `TerminalRendering` 只有同文件或 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 translation/presentation 辅助函数改为源码私有,在包内测试。 | +| `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试 provider 行为。 | +| `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 改为源码私有;通过 spawn 和 dispose 测试。 | +| `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 | +| `LlmError.status` 与 replay status | 适配器/replay 填充它,但生产分支基于稳定的 error code/message 判断,从不读取原始 status。 | 移除未读字段和 replay 管道,保留错误分类。 | | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 | -| `compactRegion` 的独立 `session` 参数 | 固定调用者传入的对象与 `agent.session` 已经是同一个;模型可见的 mount API 也能调用该方法,但接受两个身份允许挂载的插件提供不一致的配对。 | 保留手动区域 seam,同时有意将其收窄为以 `agent.session` 为唯一真源。 | -| `CompactionResult.startSeq`、`summarySeq`、`endSeq` 和 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有摘要和事件标识。 | 移除四个结果回显,同时保留两个共享的 transcript(文本记录)渲染器。 | -| `BasicCompactService` 的 estimation/summarization 可见性 | 没有包外生产调用者调用这五个方法;已实现的 RFC 仅将 `estimateContentTokens()` 和 `summarize()` 列为子类钩子。 | 将这两个方法设为 `protected`,将三个仅用于编排的估算器设为 private。 | -| `CodeLogEntry.source`/`level` 和 `RunCodeMeta.dispatches` | 所有生产消费方将日志映射为文本;没有 presenter/模型路径读取其他字段或持久化的 dispatch 计数。 | 将 code-runtime 日志改为字符串(或纯文本条目),移除 result-meta dispatch 管道;保留用于生成确定性 dispatch id 的本地计数器。 | -| `ToolNotFoundError.toolName`、`SystemPrompt.config` 和 `BashTask.command` | 每个存储的公开值都没有生产读取者。 | 移除未读字段,同时保留错误消息、已解析的配置行为和任务生命周期。 | -| 后端包根实现辅助函数 | 下方精确清单仅通过相对同包导入调用。生产命名空间导入挂载的是保留的插件契约,不读取这些属性;具名根消费方是测试。 | 保留每个适配器/提供方/服务及其配置/错误契约;停止在包根导出所列辅助函数/常量。 | -| 消费方包根实现辅助函数 | 下方精确清单仅有同包生产调用者。生产命名空间导入挂载插件契约,不读取辅助属性;具名根消费方是测试。 | 保留插件契约和稳定错误码;将测试移至包内模块或公开行为,停止在包根导出所列辅助函数。 | +| `compactRegion` 的独立 `session` 参数 | 固定调用者传入的对象与 `agent.session` 上已有的是同一个;模型可见的 mount API 也能调用该方法,但接受两个身份允许挂载的插件提供不一致的配对。 | 保留手动 region seam,同时有意将其收窄为以 `agent.session` 为唯一真源。 | +| `CompactionResult.startSeq`、`summarySeq`、`endSeq` 与 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有 summary 和事件标识。 | 移除四个结果回显,保留两个共享的 transcript(文本记录)渲染器。 | +| `BasicCompactService` 的 estimation/summarization 可见性 | 没有包外生产调用者调用这五个方法;已实现的 RFC 只将 `estimateContentTokens()` 和 `summarize()` 命名为子类钩子。 | 将这两个方法改为 `protected`,其余三个编排专用的估算器改为 private。 | +| `CodeLogEntry.source`/`level` 与 `RunCodeMeta.dispatches` | 每个生产消费方都将日志映射为文本;没有 presenter/模型路径读取其他字段或持久化的 dispatch 计数。 | 将 code-runtime 日志改为字符串(或纯文本条目),移除 result-meta 的 dispatch 管道;保留用于生成确定性 dispatch id 的本地计数器。 | +| `ToolNotFoundError.toolName`、`SystemPrompt.config` 与 `BashTask.command` | 每个存储的公开值都没有生产读取者。 | 移除未读字段,保留错误消息、已解析的配置行为和任务生命周期。 | +| 后端包根实现辅助函数 | 下方精确清单仅通过相对路径的同包导入调用。生产命名空间导入挂载的是保留的插件契约,不读取这些属性;命名根消费方都是测试。 | 保留每个适配器/provider/服务及其配置/错误契约;停止在包根导出所列辅助函数/常量。 | +| 消费方包根实现辅助函数 | 下方精确清单只有同包生产调用者。生产命名空间导入挂载的是插件契约,不读取辅助属性;命名根消费方都是测试。 | 保留插件契约和稳定的错误码;将测试迁移到包内模块或公开行为,停止在包根导出所列辅助函数。 | ### 分组辅助导出清单 -- `dsh-llm-deepseek`:`httpErrorCode`、`serializeMessages`、`serializeRequest`、`DONE`、`parseSse`、`mapFinishReason`、`mapUsage` 和 `translate`;`dsh-llm-pi-ai`:`buildModel`、`mapStopReason`、`mapUsage`、`toPiContext` 和 `toStreamChunks`。 -- `dsh-bash-local`:`DEFAULT_GRACE_MS`、`ENV_OVERRIDES`、`killGroup`、`OutputCollector` 和 `runBash`;`dsh-bash-sandbox`:`shellQuote`、`classifyDenial` 和 `classifyRunnerFailure`;`dsh-sandbox-local`:`bwrapProfileArgs`、`landlockProfileArgs` 和 `seatbeltProfileArgs`。公开的可变测试注入字段及其类型不在本提案范围内。 -- `dsh-fs-local`:`applyLiteralEdit`、`listDirectory`、`probe`、`readForEdit`、`readTextForDiff`、`readWholeText`、`resolveLocalTarget`、`restoreLineEndings`、`streamWholeText` 和 `writeFileAtomic`。 -- `dsh-web-fetch-local`:`classifyContentType`、`decoderForCharset`、`isSameOrigin`、`parseCharset` 和 `validateFetchUrl`;`dsh-web-search-exa`:`mapExaResponse` 和 `mapExaResult`;`dsh-web-search-deepseek`:`citationSnippets` 和 `mapAnthropicResponse`;`dsh-web-search-perplexity`:`mapPerplexityResponse` 和 `mapPerplexityResult`。 -- `dsh-tool-fs`:`READ_LIMIT`、`STREAM_MIN_SIZE`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`DIFF_CONTEXT`、`applyReadTool`、`parseReadArgs`、`applyWriteTool`、`formatWriteOutput`、`parseWriteArgs`、`applyEditTool`、`formatEditOutput`、`parseEditArgs`、`buildWindow`、`formatReadOutput`、`computeHunkDiffs` 和 `diffsFromMeta`。 -- `dsh-tool-web`:`WEB_SEARCH_MAX_RESULTS`、`applyWebSearchTool`、`formatSearchOutput`、`parseSearchArgs`、`presentSearchCall`、`applyWebFetchTool`、`formatFetchOutput`、`parseFetchArgs`、`presentFetchCall`、`renderBody` 和 `htmlToMarkdown`;`dsh-timeout-policy`:`toolTimeoutResult`;`dsh-compact-basic`:`resolveConfig`;`dsh-tool-bash`:`renderResult`。 +- `dsh-llm-deepseek`:`httpErrorCode`、`serializeMessages`、`serializeRequest`、`DONE`、`parseSse`、`mapFinishReason`、`mapUsage` 与 `translate`;`dsh-llm-pi-ai`:`buildModel`、`mapStopReason`、`mapUsage`、`toPiContext` 与 `toStreamChunks`。 +- `dsh-bash-local`:`DEFAULT_GRACE_MS`、`ENV_OVERRIDES`、`killGroup`、`OutputCollector` 与 `runBash`;`dsh-bash-sandbox`:`shellQuote`、`classifyDenial` 与 `classifyRunnerFailure`;`dsh-sandbox-local`:`bwrapProfileArgs`、`landlockProfileArgs` 与 `seatbeltProfileArgs`。公开的可变测试注入字段及其类型不在本提案范围内。 +- `dsh-fs-local`:`applyLiteralEdit`、`listDirectory`、`probe`、`readForEdit`、`readTextForDiff`、`readWholeText`、`resolveLocalTarget`、`restoreLineEndings`、`streamWholeText` 与 `writeFileAtomic`。 +- `dsh-web-fetch-local`:`classifyContentType`、`decoderForCharset`、`isSameOrigin`、`parseCharset` 与 `validateFetchUrl`;`dsh-web-search-exa`:`mapExaResponse` 与 `mapExaResult`;`dsh-web-search-deepseek`:`citationSnippets` 与 `mapAnthropicResponse`;`dsh-web-search-perplexity`:`mapPerplexityResponse` 与 `mapPerplexityResult`。 +- `dsh-tool-fs`:`READ_LIMIT`、`STREAM_MIN_SIZE`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`DIFF_CONTEXT`、`applyReadTool`、`parseReadArgs`、`applyWriteTool`、`formatWriteOutput`、`parseWriteArgs`、`applyEditTool`、`formatEditOutput`、`parseEditArgs`、`buildWindow`、`formatReadOutput`、`computeHunkDiffs` 与 `diffsFromMeta`。 +- `dsh-tool-web`:`WEB_SEARCH_MAX_RESULTS`、`applyWebSearchTool`、`formatSearchOutput`、`parseSearchArgs`、`presentSearchCall`、`applyWebFetchTool`、`formatFetchOutput`、`parseFetchArgs`、`presentFetchCall`、`renderBody` 与 `htmlToMarkdown`;`dsh-timeout-policy`:`toolTimeoutResult`;`dsh-compact-basic`:`resolveConfig`;`dsh-tool-bash`:`renderResult`。 ## 提案 -以一次有界的、协调的公开接口面清理,移除或降级上述每一行。更新 package README、JSDoc、生成的 API/事件目录、type-equiv 记录、必要时的 exports map 以及测试,使测试通过所属的公开 seam 来验证行为,而非保留仅为测试而存在的入口点。不折叠任何能力 seam、LLM(大语言模型)适配器、持久化后端或生命周期静默契约。 +以一次有界的、协调的公开接口清理,移除或降级上述每一行。同步更新包 README、JSDoc、生成的 API/事件 catalog、type-equiv 记录、必要的 exports map 以及测试,使测试通过所属的公开 seam 验证行为,而非保留仅为测试而存在的入口。不折叠任何能力 seam、LLM(大语言模型)适配器、持久化后端或生命周期静默契约。 ## 曾考虑的替代方案 -**保留测试便利函数和自包含结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更符合人体工学,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;今天它们让每一处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小契约,其所有权和失败语义已知。 +**保留测试便利函数和自包含的结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更符合人体工学,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;当前它们让每处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小契约,其所有权和失败语义明确。 -**为模型编写的 mount 保留所有编目成员。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务面,而非无限期保留重复字段或不一致的参数对;上述每一项编目收缩都移除了在同一次执行、agent(智能体)或结果上其他位置已可获得的事实,并在同一个变更中更新 API 参考。 +**保留所有 catalog 成员以供模型编写的 mount 使用。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务接口,而非无限期保留重复字段或不一致的参数对;上述每一项 catalog 收缩都移除了在同一 execution、agent 或 result 上其他位置已可获得的事实,并在同一变更中更新 API 参考。 ## 验收标准 -- 精确符号搜索显示被移除的接口面不出现在本 RFC 和任何已实现 RFC 修正案之外。 -- 本 RFC 列出的每一项接口面均已按指定方式移除或降级;清单之外有意保留的扩展/测试契约不受影响。 -- 工具执行、压缩(compaction)、两个 LLM 适配器、两个持久化后端、工作流隔离以及 agent 创建/恢复保持其已交付行为。 -- 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建和 hygiene 全部通过。 +- 精确符号搜索显示:在本 RFC 及任何已实现 RFC 修正之外,没有被移除的接口。 +- 本 RFC 列出的每个接口均按指定方式缺失或降级;清单之外有意保留的扩展/测试契约不变。 +- 工具执行、上下文压缩(context compaction)、两个 LLM 适配器、两个持久化后端、workflow 隔离以及 agent 创建/恢复保持其已交付行为。 +- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建和 hygiene 通过。 ## 风险 -大多数移除在编译时可见但运行时无影响。压缩参数清理有意禁止 session/context 不匹配,同时保留手动区域 seam。外部预发布嵌入者和现有模型编写的 mount 可能导入更少的辅助函数、传入更少的参数或接收更窄的结果形状;这是有意的产品接口面收缩,而非仅仅是生成目录的清理。仓库尚未发布,因此承载不受支持的接口面才是更大的基础成本。 +大多数移除在编译时可见但对运行时无影响。上下文压缩参数清理有意禁止 session/context 不匹配,同时保留手动 region seam。外部预发布嵌入者和现有模型编写的 mount 可能导入更少的辅助函数、传递更少的参数或接收更窄的结果形状;这是有意的产品接口收缩,而非仅仅是生成 catalog 的清理。仓库尚未发布,因此承载不受支持的接口才是更大的基础成本。 diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml index f7a882d65c..2e91f9e9c2 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-simplify-session-log-representation.md: 52720231d6e4cbe0cbb412332cd016ba63f83569 -2026-07-12-simplify-session-log-representation.zh.md: 468f9a565177089c8d49c06e8d490ab56980054a +2026-07-12-simplify-session-log-representation.zh.md: 1286a7d3c571fac66310a613c548920c3f25812d diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md index 468f9a5651..1286a7d3c5 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md @@ -6,33 +6,33 @@ Status: proposed ## 问题 -会话日志维护着两种表示,其机制开销超出了消费方的实际需求:伪链表 surface 与自定义请求头增量编码。 +会话日志维护着两种表示,其机制复杂度超出了消费方的实际需求:一个伪链表 surface 和自定义的请求头增量。 -`SurfaceManager` 将同一顺序存储在数组、seq 映射和可变的 `prev`/`next` 链接三处。生产代码从不读取 `prev`;压缩(compaction)唯一一次读取 `next` 是取数组位置的后继。替换操作已经使用 `indexOf`,因此链接并未使其主要操作达到常数时间。一个 seq 数组加线性替换查找具有相同的渐近替换开销,且只有一种表示需要校验。 +`SurfaceManager` 用一个数组、一个 seq 映射和可变的 `prev`/`next` 链接存储相同的顺序。生产代码从不读取 `prev`;压缩(compaction)唯一的 `next` 读取是取数组位置的后继。替换操作已经使用 `indexOf`,因此链接并未让其主要操作达到常数时间。一个 seq 数组加线性替换查找具有相同的渐近替换开销,且只有一种表示需要验证。 -请求头子系统实现了自定义的 system/tool 增量编解码器与传输决策层,尽管其契约声明增量只是编码优化而非可重建性要求。在每个 agent loop 实例边界保留 initial/resume 完整快照,然后在该实例的组装头发生变化时写入一条规范的完整 `request/header`,即可保留回放能力,同时删除 `SystemDelta`、`ToolsDelta`、往返 fallback 以及持久化的 `request/header-delta` 变体。编解码器专用词汇随编解码器一起消失,并非因为其各分支本身无效。 +请求头子系统实现了一套自定义的 system/tool 增量编解码器和传输决策层,尽管其契约声明增量只是编码优化,而非可重建性要求。在每个 agent loop(智能体循环)实例边界保留初始/恢复的完整快照,然后在该实例的组装头发生变化时写入一条规范的完整 `request/header`,即可保留回放能力,同时删除 `SystemDelta`、`ToolsDelta`、往返回退逻辑以及持久化的 `request/header-delta` 变体。编解码器专属的词汇随编解码器一起消失,并非因为其各分支本身无效。 -本提案有意保留 append 与 replacement 的 `sourceEventSeqs`、崩溃恢复溯源,以及所有 `SessionStartSource` 变体:已实施的 RFC 赋予了这些字段审计/拦截角色,零当前读者不足以推翻这一点。 +本提案有意保留追加和替换的 `sourceEventSeqs`、崩溃恢复来源信息以及所有 `SessionStartSource` 变体:已实施的 RFC 赋予这些字段审计/拦截角色,零当前读者这一事实不足以推翻它们。 ## 提案 -将 `SurfaceManager.nodes` 改为事件序列号的 `readonly number[]`,移除公开的 `SurfaceNode` 形状。保留内部的 replace-generation 信号;更新工具配对平衡与压缩调用方,使其通过数组值/索引获取前驱、后继与替换范围,移除节点链接与 seq-to-node 映射。将锚点后的请求头增量替换为规范的完整变更头快照,移除增量编解码器/事件/测试;initial 与 resume 锚点即使折叠后的头未变也仍为完整快照。 +将 `SurfaceManager.nodes` 改为事件序列号的 `readonly number[]`,移除公开的 `SurfaceNode` 形状。保留内部的替换代信号;更新 tool 配对平衡和压缩调用方,使其通过数组值/索引获取前驱、后继和替换范围,移除节点链接和 seq-to-node 映射。用规范的完整变更头快照替代锚点后的头增量,移除增量编解码器/事件/测试;初始和恢复锚点即使折叠后的头未变也仍为完整快照。 -修订会话 surface 与可重建请求的 RFC 中描述已移除编码的部分。更新事件类型/不变式、请求日志/回放、持久化 fixture(测试前置数据)、生成的 catalog、包文档与快照。将编解码器专用的 `fallback` 原因替换为显式的 `change` 原因(用于锚点后的完整快照),以区别于保留的 `initial` 与 `resume` 锚点。 +修订 session-surface 和 reconstructable-request RFC 中描述已移除编码的部分。更新事件类型/不变式、请求日志/回放、持久化 fixture(测试前置数据)、生成的 catalog、包文档和快照。将编解码器专属的 `fallback` 原因替换为锚点后完整快照的显式 `change` 原因,使其与保留的 `initial` 和 `resume` 锚点区分开来。 -`SESSION_FORMAT_VERSION` 有意保持为 `0`,因此包含 `request/header-delta` 的旧 v0 日志在增量折叠被删除后,若不做处理将通过版本检查并静默丢失头变更。seed/load 校验必须在格式边界处拒绝该遗留事件并快速失败;不添加兼容折叠或迁移。 +`SESSION_FORMAT_VERSION` 有意保持在 `0`,因此一份包含 `request/header-delta` 的旧 v0 日志在增量折叠被删除后,本会通过版本检查并静默丢失头变更。seed/load 校验必须在格式边界处拒绝该遗留事件并显式报错;不添加兼容性折叠或迁移。 ## 曾考虑的替代方案 -**保留链表节点与紧凑增量以备未来规模。** 链接可能有助于未来的游标 API,增量在大型工具 schema 仅有少量变化时能减小日志体积。但没有已发布的游标使用这些链接,而完整快照以磁盘空间换取显著更简单的正确性。如果头部体积确实成为问题,可以基于真实 trace 设计压缩方案或经过度量的规范增量方案。 +**保留链表节点和紧凑增量以备未来扩展。** 链接可能有助于未来的游标 API,增量在大型工具 schema 仅有少量变化时可以缩减日志。但没有已发布的游标使用这些链接,而完整快照以磁盘空间换取了显著更简单的正确性。如果头部体积确实成为问题,可以基于真实 trace 设计压缩方案或经过度量的规范增量方案。 ## 验收标准 -- `SurfaceManager.nodes` 是一个有序 seq 数组,没有 `SurfaceNode`、链接字段或 seq-to-node 映射;增量追加处理与内部 replace-generation 信号保留。 +- `SurfaceManager.nodes` 是一个有序 seq 数组,没有 `SurfaceNode`、链接字段或 seq-to-node 映射;增量追加处理和内部替换代信号保留。 - 回放完整变更头快照能重建出完全相同的请求;不再存在任何 header-delta 事件/类型/编解码器。 -- 包含遗留 `request/header-delta` 的 v0 seed 或持久化日志在回放前被拒绝,JSONL 与 SQLite 加载路径均有覆盖。 -- 新形状的 v0 JSONL/SQLite 回放、溯源、崩溃恢复、压缩、快照、不变式、类型检查、覆盖率、doc-sync、构建与 hygiene 全部通过。 +- 包含遗留 `request/header-delta` 的 v0 seed 或持久化日志在回放前被拒绝,JSONL 和 SQLite 加载路径均有覆盖率。 +- 新形状的 v0 JSONL/SQLite 回放、来源信息、崩溃恢复、压缩、快照、不变式、类型检查、覆盖率、doc-sync 和 hygiene 全部通过。 ## 风险 -完整头会增加日志体积,线性替换查找在非常大的 surface 上可能更慢。替换操作目前已经是线性的,因为实现调用了 `indexOf`;只有在真实 trace 表明更简单的数组成为瓶颈时才应添加基准测试。由于格式版本保持为 `0`,如果遗漏了对遗留事件的显式拒绝,后果将是静默数据损坏而非类型错误;因此快速失败的加载测试是本提案的组成部分,而非可选的清理工作。 +完整头会增加日志体积,线性替换查找在非常大的 surface 上可能更慢。替换操作已经是线性的,因为实现调用了 `indexOf`;只有当真实 trace 表明更简单的数组成为瓶颈时才应添加基准测试。由于格式版本保持为 `0`,如果遗漏了对遗留事件的显式拒绝,后果将是静默数据损坏而非类型错误;因此显式报错的加载测试是本提案的组成部分,而非可选的清理工作。 diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml index 3e3afa30d0..13dc3e72b9 100644 --- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-deterministic-and-stress-testing.md: e4ed7043d1880b55dd7d77b3e09a81dd58739a70 -2026-06-11-deterministic-and-stress-testing.zh.md: d933c1dda329b95573b7a5a3fbe3a61ef94390cb +2026-06-11-deterministic-and-stress-testing.zh.md: 4e4ee9d28025a43349b702e399096535539a91d2 diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md index d933c1dda3..4e4ee9d280 100644 --- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md @@ -6,28 +6,28 @@ Status: proposed ## 问题 -若干 agent loop(智能体循环)测试通过 `setTimeout(30)` 睡眠来同步——这是一笔不稳定性债务,浪费 agent 重试周期,还可能掩盖排序 bug。另一方面,我们的核心架构承诺(任何会话日志回放后都能得到完全相同的派生历史)目前只在两个测试中断言,但在*所有地方*断言的成本很低。此外,inbox 唤醒竞态只被手动验证过一次,没有任何东西持续地重新验证它。 +若干 agent loop(智能体循环)测试通过 `setTimeout(30)` 睡眠来同步——这是一笔不稳定性债务,浪费 agent 的重试周期,还可能掩盖时序 bug。另外,我们的核心架构承诺(任何会话日志回放后都能得到相同的派生历史)目前只在两个测试中断言,但在**所有**测试中断言的成本极低。此外,inbox 唤醒竞态只被手动验证过一次,没有任何机制持续复验。 ## 提案 三项措施: -1. **测试中禁止挂钟睡眠。** 将 `setTimeout(N)` 等待替换为事件驱动等待(现有的 `waitForIdle` 模式,扩展为 `waitForStatus`、`waitForEvent(n)`),或在需要测试时间本身时使用 vitest fake timers。通过 lint 规则强制:禁止在 `packages/*/tests` 中使用 `setTimeout`,白名单辅助模块除外。 -2. **通用回放 fixture(测试前置数据)。** 一个共享的测试辅助函数包装 agent loop harness,使得每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被检查数百次(覆盖套件产生的所有场景),而非仅两次。 -3. **夜间竞态压力测试。** 一个 CI job 以 `vitest --repeat=200`(加 `--shuffle`)运行 agent-loop 和 inbox 套件,以暴露调度依赖的失败;发现的任何不稳定测试都作为 bug 修复,绝不靠重试掩盖。 +1. **测试中禁止挂钟睡眠。** 将 `setTimeout(N)` 等待替换为事件驱动等待(既有的 `waitForIdle` 模式,扩展为 `waitForStatus`、`waitForEvent(n)`),或在需要测试时间本身时使用 vitest 的 fake timer。通过 lint 规则强制执行:禁止在 `packages/*/tests` 中使用 `setTimeout`,白名单辅助模块除外。 +2. **通用回放 fixture(测试前置数据)。** 一个共享测试辅助函数包装 agent loop harness,使每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被套件产生的所有场景检查数百次,而非仅两次。 +3. **夜间竞态压力测试。** 一个 CI job 以 `vitest --repeat=200`(加 `--shuffle`)运行 agent-loop 和 inbox 套件,以暴露调度依赖的失败;发现的任何不稳定测试都视为 bug 修复,绝不靠重试掩盖。 ## 计划 -措施 1 和 2 一起落地(它们改动相同的辅助模块);在套件消除所有睡眠之后再添加夜间 job,使重复运行足够快。 +措施 1 和 2 一起落地(它们改动相同的辅助模块);在套件消除所有睡眠后再添加夜间 job,以确保重复运行速度快。 ## 验收标准 -- `packages/*/tests` 中不再有 `setTimeout`(白名单辅助模块除外),由 lint 规则强制。 -- 共享 harness 对每个测试的会话日志进行回放,将其注入全新的 `Session` 并自动断言 `deriveMessages()` 相等,覆盖整个套件。 -- 夜间 job 以 `--repeat` 和 `--shuffle` 运行 agent-loop 和 inbox 套件;发现的不稳定测试作为 bug 分诊处理,绝不靠重试掩盖。 +- `packages/*/tests` 中不再有 `setTimeout`(白名单辅助模块除外),由 lint 规则强制执行。 +- 共享 harness 将每个测试的会话日志回放到全新的 `Session` 中,并自动断言 `deriveMessages()` 相等,覆盖整个套件。 +- 夜间 job 以 `--repeat` 和 `--shuffle` 运行 agent-loop 和 inbox 套件;发现的不稳定测试作为 bug 分诊,绝不靠重试掩盖。 ## 风险 -Fake timers 与 agent loop 中的 Promise 调度存在微妙交互——优先使用事件驱动等待;仅在测试 timer 服务行为本身时才使用 fake timers。 +Fake timer 与 agent loop 中的 Promise 调度存在微妙交互——优先使用事件驱动等待;仅在测试 timer 服务行为本身时才使用 fake timer。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml index 3f6c6157fa..3bf5967b45 100644 --- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-mutation-testing.md: 344263d1c91a5e6c83320f367bf76ed6f7ef5a49 -2026-06-11-mutation-testing.zh.md: aa89b3335a143b6f34858bdc2f3344a6a7d758d9 +2026-06-11-mutation-testing.zh.md: 28bb7253c12827dbcddd141481f26f60b3a72b7a diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md index aa89b3335a..28bb7253c1 100644 --- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md @@ -1,4 +1,4 @@ -# RFC:变异测试作为覆盖率的制衡 +# RFC:变异测试作为覆盖率的制衡手段 [English](2026-06-11-mutation-testing.md) | 中文 @@ -6,31 +6,31 @@ Status: proposed ## 问题 -逐文件 100% 覆盖率门禁(见[质量门禁决策](../../implemented/process/2026-06-11-quality-gates.md))证明的是每一行都在测试中*被执行*了,而非任何断言会在该行出错时有所察觉。在 agent 编写测试的场景下,覆盖率压力可能催生「执行但无断言」的测试。变异测试衡量的正是覆盖率无法衡量的:测试套件是否能*杀死*被刻意注入的缺陷。 +逐文件 100% 覆盖率门禁([质量门禁决策](../../implemented/process/2026-06-11-quality-gates.md))证明每一行代码在测试中都被*执行*了,但不能证明如果该行出错,任何断言会注意到。在 agent(智能体)编写测试的场景下,覆盖率压力可能产出「执行但不断言」的测试。变异测试衡量的正是覆盖率无法衡量的:测试套件是否能*杀死*被刻意注入的缺陷。 ## 提案 在 `packages/*/src` 上运行 Stryker(`@stryker-mutator/vitest-runner`): -- **PR 粒度的增量运行**(仅变更文件),作为 CI job:调优后足够快,可以作为合并门禁。 -- **每夜全量运行**,跟踪变异分数;先记录基线,再将阈值设为观测到的基线值并只升不降(与覆盖率策略一致:阈值只收紧)。 -- 存活的变异体是待办工作项:agent 选取一个存活体、编写杀死它的测试、循环往复——一个形态良好的自主循环。 -- 等价变异体(可证明不改变行为的)加带理由的排除注解,与 `/* v8 ignore */` 策略对称。 +- **PR 范围的增量运行**(仅变更文件),作为一个 CI job。调优后速度足以作为合并门禁。 +- **每夜全量运行**,跟踪变异分数;先记录基线,再将阈值设为观测到的基线并只升不降(与覆盖率策略一致:阈值只收紧)。 +- 存活的变异体是待办项:agent 选取一个存活体、编写杀死它的测试、循环往复——一个形态良好的自主循环。 +- 等价变异体(可证明不改变行为的)加注释排除并附理由,与 `/* v8 ignore */` 策略一致。 ## 计划 1. 添加 Stryker 配置,范围限定在一个包(llm:最小、最具算法性),测量运行时间。 2. 扩展到所有包;在配置中记录基线分数。 -3. 接入每夜 job;当运行时间可接受后,添加 PR 粒度的增量 job。 +3. 接入每夜 job;运行时间可接受后再添加 PR 范围的增量 job。 ## 验收标准 -- Stryker 配置在 `packages/*/src` 上以 vitest runner 运行;每夜 job 记录变异分数,且当分数低于记录的基线时,运行失败(阈值只升不降)。 -- PR 粒度的增量运行在运行时间可接受后作为合并门禁;或者明确保持仅每夜运行,并将该结论记录于此。 -- 等价变异体带有附理由的排除注解,与 `/* v8 ignore */` 策略对称。 +- Stryker 配置在 `packages/*/src` 上以 vitest runner 运行;每夜 job 记录变异分数,当分数低于记录的基线时,通过只升不降的阈值使运行失败。 +- PR 范围的增量运行在运行时间可接受后作为合并门禁;或者明确保持仅每夜运行,并将该结论记录于此。 +- 等价变异体带有注释排除及理由,与 `/* v8 ignore */` 策略一致。 ## 风险 -运行时间:变异测试开销大;逐文件 100% 覆盖率有所帮助(每个变异体至少会被执行到)。如果 PR 粒度的运行始终太慢,则保持仅每夜运行,依赖分数只升不降的机制。 +运行时间:变异测试开销大;逐文件 100% 覆盖率有所帮助(每个变异体至少会被执行到)。如果 PR 范围的运行始终过慢,则保持仅每夜运行,依赖分数只升不降的机制。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml index 3c2a50a714..f7c21ad005 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-immutable-public-surfaces.md: 68472d9817f0de22777314927f05f90949903723 -2026-06-11-immutable-public-surfaces.zh.md: e067b48d9a936133abdf149fbb9ff626c9411851 +2026-06-11-immutable-public-surfaces.zh.md: 7dc42ef9ff07682c1bbac1ca61caba49596cb7bc diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md index e067b48d9a..7dc42ef9ff 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md @@ -2,28 +2,28 @@ [English](2026-06-11-immutable-public-surfaces.md) | 中文 -Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). +Status: rejected — 全面使用 `DeepReadonly<T>` 类型翻转的方案已被替换为 `Session` 中由源拥有的运行时不可变性加关系型开发断言。见[源拥有的会话不可变性与开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)。 ## 问题 -被否决的提案针对的是一个所有权漏洞:仅靠 `readonly SessionEvent[]` 类型无法封堵该漏洞,因为数组元素在运行时仍然可变,一次类型断言或纯 JavaScript 就能改写嵌套的历史记录。最终实现的设计在 `Session` 中通过物化并深度冻结每个已接受的事件、返回冻结的数组快照来封堵该漏洞。进行中的 prompt waterfall(瀑布式事件)被有意保留为可变,因此不可变性是一条所有权边界,而非一条覆盖全局的类型规则。 +被否决的提案针对的是一个所有权漏洞:仅靠 `readonly SessionEvent[]` 类型无法封堵该漏洞,因为其元素在运行时仍然可变,类型强制转换或纯 JavaScript 代码可以改写嵌套的历史记录。已实现的设计在 `Session` 中封堵了这一漏洞:对每个被接受的事件进行物化并深度冻结,返回冻结的数组快照。进行中的 prompt waterfall(瀑布式事件)有意保持可变换,因此不可变性是一条所有权边界,而非一条全局类型规则。 ## 提案 -> **实际实现方式不同——见 Status 行与 [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)。** 下文的 `DeepReadonly<T>` 设计已被否决:它仅在编译期生效、对消费方噪音大、且可被 cast 绕过。`Session` 改为在每次组合中对已接受的事件和公开日志快照进行快照与深度冻结;`deriveMessages()` 返回分离的冻结投影;开发插件检查跨记录与跨 seam 的关系约束。 +> **实际采用了不同的实现方式——见 Status 行与[源拥有的会话不可变性与开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)。** 下文的 `DeepReadonly<T>` 设计已被否决:它仅在编译期生效、对消费方噪音大、且可被强制转换绕过。`Session` 改为在每次组合中对已接受的事件和公开日志快照进行快照与深度冻结;`deriveMessages()` 返回分离的冻结投影;开发插件检查跨记录与跨 seam 的关系。 -在类型层面将不可变性施加于「变异即腐败」的位置: +在类型层面为「突变即损坏」的场景引入不可变性: -- `SessionEvent` 数据在从会话**输出**时(`events`、`session/event` 监听器)变为 `DeepReadonly`;`append()` 仍接受普通可变输入。一个 `DeepReadonly<T>` 工具类型放入 dsh-llm,与 brand/never 辅助类型并列。 -- `deriveMessages()` 返回深度只读的消息;agent loop(智能体循环)在将可变请求交给 `agent/request` waterfall 之前先克隆一份(在 waterfall 中变异是被允许的——克隆使边界显式且廉价,每步仅一次)。 -- `PromptAssembly` 在其 waterfall 流程中保持可变(被允许),但注册表的内部 section 列表在每次组装时被克隆(已有此行为)。 +- `SessionEvent` 数据在从会话**输出**时(`events`、`session/event` 监听器)变为 `DeepReadonly`;`append()` 仍接受普通可变输入。一个 `DeepReadonly<T>` 工具类型放在 dsh-llm 中,与 brand/never 辅助类型相邻。 +- `deriveMessages()` 返回深度只读的消息;agent loop(智能体循环)在将可变请求交给 `agent/request` waterfall 之前先克隆(该处的突变是被允许的——克隆使边界显式且代价低廉,每个步骤仅一次)。 +- `PromptAssembly` 在其 waterfall 流经期间保持可变(被允许),但注册表内部的 section 列表在每次组装时被克隆(已有此行为)。 ## 计划 -引入 `DeepReadonly`,翻转会话的读取路径,并修复消费方由此产生的编译错误。 +引入 `DeepReadonly`,翻转会话的读取路径,并修复消费方中由此产生的编译错误。 ## 风险 -`DeepReadonly` 类型会在 waterfall 边界处产生噪音错误——因为变异在那里正是 API 的一部分。应将可变/只读边界严格限定在「已记录 vs 进行中」,并在会话 README 中加以说明。 +`DeepReadonly` 类型在 waterfall 边界处(突变本身就是 API 的地方)可能产生噪音较大的错误。应将可变/只读边界精确地划在「已记录 vs 进行中」,并在 session README 中加以说明。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml index ba7de6f475..11ed0efa0b 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-providerless-example-base.md: be5122ec6665dcea15619f3cb4b3ed3a2fa03972 -2026-06-20-providerless-example-base.zh.md: 011636bbdfb782a5b6e4c03695cd0a4a428993d5 +2026-06-20-providerless-example-base.zh.md: f01451f719f0fe1bbb50806aea40086e7fe08350 diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md index 011636bbdf..f01451f719 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md @@ -1,4 +1,4 @@ -# RFC:使共享示例基础配置不依赖提供方 +# RFC:使共享示例基础配置与提供方无关 [English](2026-06-20-providerless-example-base.md) | 中文 @@ -6,26 +6,26 @@ Status: rejected — superseded by [Extract example apps into packages](../../im ## 问题 -示例曾有两个共享基础文件:`examples/base-core.yml` 不依赖任何模型提供方,而 `examples/base.yml` 在此核心之上加入了真实的 `llm-deepseek` 适配器。快照回放需要搭配 `llm-replay` 使用那个提供方无关的核心,因为在没有 key 的情况下加载真实适配器会抛错。常规演示则需要真实适配器。结果是命名倒挂:名为 `base.yml` 的文件并非所有示例的可复用基础,而真正的基础反而叫 `base-core.yml`。 +示例曾有两个共享基础文件:`examples/base-core.yml` 与提供方无关,而 `examples/base.yml` 在该核心基础上加入了真实的 `llm-deepseek` 适配器。快照回放需要与提供方无关的核心配合 `llm-replay` 使用,因为在没有密钥的情况下加载真实适配器会抛出异常。常规演示则需要真实适配器。结果是命名与实际含义倒挂:名为 `base.yml` 的文件并非所有示例可复用的基础,而真正的基础反倒是 `base-core.yml`。 -这种拆分可以理解,但它让每次解释配置都变得更长。它还导致了别扭的测试搭建方式:keyless 冒烟测试需要携带一个假 API key 才能让适配器启动,尽管模型根本不会被调用。 +这种拆分可以理解,但它让每次解释配置都变得更冗长。它还导致了别扭的测试搭建方式,例如无密钥冒烟测试不得不携带一个虚拟 API key,仅仅为了让适配器能启动——尽管模型根本不会被调用。 ## 提案 -将提供方无关的核心重命名为 `examples/base.yml`,让适配器选择在每个具体示例中显式声明。编码与 ACP 真实配置添加一小段 `llm-deepseek` include 或本地块;快照配置添加 `llm-replay`。删除 `examples/base-core.yml`。 +将与提供方无关的核心重命名为 `examples/base.yml`,让适配器选择在每个具体示例中显式声明。编码和 ACP 真实配置添加一小段 `llm-deepseek` include 或本地块;快照配置添加 `llm-replay`。删除 `examples/base-core.yml`。 -共享基础应当只包含提供方无关的服务与工具:`llm`、会话、系统提示词、工具、agent、不变式、bash 执行器与 bash 工具 schema。任何选择模型提供方的内容都属于叶子配置。 +共享基础应仅包含提供方无关的服务与工具:`llm`、会话、系统提示词、工具、agent、不变式、bash 执行器和 bash 工具 schema。任何涉及模型提供方选择的内容都应放在叶子配置中。 ## 验收标准 -- `examples/base.yml` 不依赖任何提供方。 +- `examples/base.yml` 与提供方无关。 - `examples/base-core.yml` 已删除。 - 真实演示配置显式添加 DeepSeek 适配器。 -- 快照回放配置引入同一个提供方无关的基础及其回放适配器。 -- [examples README](../../../../examples/README.md)、各示例的 README 与 RFC 引用不再解释"base = base-core 加适配器"。 +- 快照回放配置 include 同一个与提供方无关的基础,并加入其回放适配器。 +- [examples README](../../../../examples/README.md)、各示例 README 及 RFC 引用不再解释「base = base-core 加适配器」。 ## 放弃了什么 -真实演示失去了一层便利:每个都必须显式引入适配器。对示例而言这是正确的默认值,因为适配器选择是可变部分,而提供方无关的接线才是共享的产品核心。 +真实演示失去了一层便利:每个演示都必须显式引入适配器。对于示例而言这是正确的默认行为,因为适配器选择是可变部分,而与提供方无关的接线才是共享的产品核心。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml index 243b308c50..ec03777980 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-assembled-assistant-messages-only.md: 7605f286cf5914a127f5f8e2b77490648b42cc30 -2026-06-20-assembled-assistant-messages-only.zh.md: e282c5bd74fd3b854c85112d64de44a5470007bc +2026-06-20-assembled-assistant-messages-only.zh.md: 05f15ffadba4607bc64fdf2f7eefdbcc41cf03a3 diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md index e282c5bd74..05f15ffadb 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md @@ -1,36 +1,36 @@ -# RFC:只持久化已组装的 assistant 消息,不持久化流式分片 - -Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. +# RFC:仅持久化组装后的 assistant 消息,不存储流式分片 [English](2026-06-20-assembled-assistant-messages-only.md) | 中文 +Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. + ## 问题 -当前的规范会话日志会逐条持久化模型流出的每个 `assistant/chunk`。[会话持久化 RFC](../../implemented/architecture/2026-06-14-session-persistence.md) 选择这一方案是为了 token 级别的回放保真度和连续的 `seq`,但代价日益增长:JSONL fixture(测试前置数据)被大量微小的 delta 记录占据;快照场景通过分组 chunk 事件来回放模型;ACP 加载时需要从 chunk 重建先前的 assistant 输出;任何未来的日志读取方都必须区分持久的消息历史与 token 级别的追踪。 +当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 RFC](../../implemented/architecture/2026-06-14-session-persistence.md) 选择这一方案是为了 token 级别的回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的 delta 记录占据,快照场景通过分组 chunk 事件来回放模型,ACP(Agent Client Protocol)加载时从 chunk 重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级别的追踪。 -对于成功完成并组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复对话状态已经存在,无需 chunk;chunk 是实时渲染和确定性测试的产物,不是必需的对话历史。失败或中止的流则不同:部分 assistant 输出可能仅以 chunk 形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。 +对于成功组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复会话状态无需 chunk 即已具备;chunk 是实时渲染和确定性测试的产物,不是必需的会话历史。失败或中止的流则不同:部分 assistant 输出可能仅以 chunk 形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。 ## 提案 -停止在规范会话日志中存储 `assistant/chunk`。持久日志只保留 `assistant/message`、`tool/call`、`tool/result`、保留的 `usage`,以及轮次边界。实时 UI 仍可通过一个刻意设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从已记录的适配器产物派生,而不是把规范的用户会话当作 token 磁带。需要部分失败流输出的场景必须在回放 fixture 中记录该输出。 +停止在规范会话日志中存储 `assistant/chunk`。持久日志保留 `assistant/message`、`tool/call`、`tool/result`、`usage`(如保留)以及轮次边界。实时 UI 仍可通过一个刻意设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从记录的适配器产物中派生,而非将规范的用户会话当作 token 磁带。需要部分失败流输出的场景必须在回放 fixture 中记录该输出。 -ACP 的 `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而不是模拟原始的 token 流。加载的 transcript(文本记录)不必重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并以有效的提供方历史恢复对话。 +ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而非模拟原始的 token 流。加载后的 transcript(文本记录)无需重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并以有效的 provider 历史恢复运行。 ## 验收标准 -- `SessionEventMap` 移除 `assistant/chunk`,或在需要过渡性实时事件时将其标记为不持久化。 -- [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)不再要求逐条存储每个流式分片。 -- `llm-replay` 与 ACP 快照使用显式的回放 fixture 格式或伴随文件来承载模型 chunk。 +- `SessionEventMap` 移除 `assistant/chunk`,或在需要过渡性实时事件时将其标记为非持久化。 +- [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)不再要求逐字存储每个流式分片。 +- `llm-replay` 和 ACP 快照使用显式的回放 fixture 格式或伴随文件来存储模型 chunk。 - `session/load` 从 `assistant/message` 渲染已完成的 assistant 消息。 - 存储的日志大幅缩小,且在没有 chunk 空洞的情况下保持 `seq` 连续。 - 会话格式版本与已记录的 fixture 一并刷新;按预发布格式策略拒绝非当前版本的存储日志。 ## 放弃了什么 -规范的用户会话不再能重建旧轮次的精确 token 流。它还会丢失失败或中止流的部分 assistant 输出,除非有其他事件或 fixture 记录了它。对于当前的恢复、加载和快照契约而言,这是过大的信息损失。需要精确确定性流的测试应当自行拥有该 fixture,前提是生产会话日志为用户可见的恢复保留了足够的保真度。 +规范的用户会话不再能重建旧轮次的精确 token 流。它也会丢失失败或中止流的部分 assistant 输出,除非另有事件或 fixture 记录。对于当前的恢复、加载和快照契约而言,这是过大的信息损失。需要精确确定性流的测试应当直接拥有该 fixture,前提是生产会话日志为用户可见的恢复保留了足够的保真度。 ## 相关 -本 RFC 取代[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)中关于 chunk 持久化的决策,并影响 [ACP 快照测试](../../implemented/testing/2026-06-19-acp-snapshot-tests.md)——其当前的回放插件从 `assistant/chunk` 事件派生脚本。 +本 RFC 取代 [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md) 中关于 chunk 持久化的决策,并影响 [ACP 快照测试](../../implemented/testing/2026-06-19-acp-snapshot-tests.md)——其当前的回放插件从 `assistant/chunk` 事件派生脚本。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml index 9d083a9f72..35f0ecedd9 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-acp-session-load.md: 39f24d313db6083db45ff6fbc4a84f504e7714cb -2026-06-20-drop-acp-session-load.zh.md: 003c66636a75e21199f3c3ac600867cf9b73b87c +2026-06-20-drop-acp-session-load.zh.md: b7339f492d5f6b7e2daab5820dada0fa2b5fe823 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md index 003c66636a..b7339f492d 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md @@ -1,29 +1,29 @@ -# RFC:移除 ACP session/load,待恢复功能具备产品形态后再引入 - -Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. +# RFC:移除 ACP session/load,直到 resume 具备产品形态 [English](2026-06-20-drop-acp-session-load.md) | 中文 +Status: rejected — Zed 是当前目标 ACP 客户端,它声明并使用支持 load 的会话,且为并发 `session/load` 维护 pending-load 状态。bridge 应保留 `session/load` 并使 resume 契约更加稳固。 + ## 问题 -ACP(Agent Client Protocol)当前通告 `loadSession: true` 并实现了 `session/load`:向 bridge 注入持久化能力、校验 cwd 与存储元数据的一致性、从持久化日志重建 agent、并向客户端回放先前的 transcript(文本记录)更新。这条路径有自己的竞态处理、loading-id 守卫、回放展示逻辑和测试。它还依赖规范日志保留足够的 UI 数据来重建旧的分片和工具展示。 +ACP(Agent Client Protocol)声明 `loadSession: true` 并实现 `session/load`:向 bridge 注入持久化能力、校验 cwd 与存储元数据的一致性、从持久化日志重建 agent(智能体),并向客户端回放先前的 transcript(文本记录)更新。该路径有自己的竞态处理、loading-id 守卫、回放展示逻辑和测试。它还依赖规范日志保留足够的 UI 数据,以重建旧的分片和工具展示。 -持久化本身仍是基础能力,但编辑器可见的恢复功能尚未经过产品流程设计。目前没有会话选择器、没有标题/预览元数据,对加载失败或部分加载也没有清晰的用户体验。bridge 正在为一个仅由测试、文档和当前目标客户端的会话模型所使用的功能承担复杂度。 +持久化仍然是基础能力,但编辑器可见的 resume 尚未经过产品流程设计。目前没有会话选择器、没有标题/预览元数据,也没有明确的加载失败或部分加载的用户体验。bridge 正在为一个仅被测试、文档和当前目标客户端的会话模型所使用的功能付出复杂度代价。 ## 提案 -暂时只支持新建会话。`initialize` 通告 `loadSession: false` 或省略该能力,`session/load` 不予支持。持久化仍可供 agent loop(智能体循环)和测试使用;如果其他消费方需要,恢复功能仍可作为底层工厂存在。编辑器 bridge 应在具备真正的会话选择 UX 和稳定的加载 transcript 契约后,再重新引入 `session/load`。 +当前阶段,ACP 仅启动全新会话。`initialize` 声明 `loadSession: false` 或省略该能力,`session/load` 不予支持。持久化仍可供 agent loop(智能体循环)和测试使用;如果其他消费方需要,resume 仍可作为底层工厂存在。编辑器 bridge 应在具备真正的会话选择 UX 和稳定的 load transcript 契约后,再重新引入 `session/load`。 ## 验收标准 - ACP 不再仅为 `session/load` 注入 `sessionPersistence`。 -- `initialize` 不通告加载支持。 -- `session/load` 处理器、loading-id 追踪、已加载会话的 cwd 预检以及加载回放测试全部移除。 -- 快照 fixture(测试前置数据)不再依赖加载回放的展示逻辑。 -- [ACP 文档](../../../../packages/ui/acp/README.md)仅描述新建会话的支持。 +- `initialize` 不再声明 load 支持。 +- `session/load` handler、loading-id 追踪、已加载会话的 cwd 预检以及 load 回放测试均被移除。 +- 快照 fixture(测试前置数据)不再依赖 load 回放展示。 +- [ACP 文档](../../../../packages/ui/acp/README.md)仅描述全新会话的支持。 -## 放弃了什么 +## 放弃的能力 -编辑器无法通过 ACP 重新打开先前持久化的会话。这确实是一个有价值的产品功能,但当前实现超前于 UX 设计,且将 bridge 绑定在 token 级别的日志回放上。保留持久化但移除编辑器加载,将 bridge 收窄到它当前能干净呈现的工作流。 +编辑器无法通过 ACP 重新打开先前持久化的会话。这确实是一项产品功能,但当前实现超前于 UX 设计,且将 bridge 绑定到 token 级别的日志回放。保留持久化但移除编辑器 load,可将 bridge 收窄到它当前能干净呈现的工作流。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index 61557e984b..16827c901b 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-acp-terminal-meta.md: 4ae3b31824ece850da0ddbc004e97b21e3cb9aad -2026-06-20-drop-acp-terminal-meta.zh.md: 62341ccae728d981400fdc22b8ec7380bbb3faf2 +2026-06-20-drop-acp-terminal-meta.zh.md: f5b3e0a4e1f37445a0b0dcbf7426806a1de89763 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index 62341ccae7..f5b3e0a4e1 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -1,31 +1,31 @@ # RFC:移除 ACP 终端 `_meta` 渲染 -Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. - [English](2026-06-20-drop-acp-terminal-meta.md) | 中文 +Status: rejected — Zed 是当前目标客户端,终端 `_meta` 约定是有意为之的 Zed UX 设计,同时为其他客户端提供纯 ACP(Agent Client Protocol)回退路径。 + ## 问题 -ACP(Agent Client Protocol)桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 专属的终端卡片约定。已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 有意避开了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 的职责),但仍采用了参考 agent 的纯展示用 `_meta` 约定。这为 Zed 带来了更好的卡片效果,代价是桥接层状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 +ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 -回退路径已经存在:将工具调用和完成输出渲染为普通的 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能,而非投机性的装饰。 +回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能特性,而非推测性装饰。 ## 提案 -忽略 `clientCapabilities._meta.terminal_output`,通过普通 ACP 内容路径渲染 bash 结果。执行仍留在 agent 侧,通过 `dsh-bash` 完成;只移除与展示相关的终端元数据。如果 ACP 日后标准化了 agent 执行的终端,或产品决定 Zed 专属展示值得维护成本,终端卡片可以回归。 +忽略 `clientCapabilities._meta.terminal_output`,通过纯 ACP 内容路径渲染 bash 结果。执行仍由 agent 侧的 `dsh-bash` 完成;仅移除展示相关的终端元数据。如果 ACP 日后标准化了 agent 执行的终端,或产品决定 Zed 特有展示值得其维护成本,终端卡片可以再回来。 -本提案比[收拢工具自有 UI 展示](2026-06-20-generic-tool-rendering.md)更窄:如果通用的 `presentCall`/`presentResult` 保留,本提案不动它们,只移除终端子形态和 `_meta` 映射。 +本提案比[收拢工具自有 UI 展示](2026-06-20-generic-tool-rendering.md)更窄:如果通用的 `presentCall`/`presentResult` 保留,本提案不影响它们,只移除终端子形态与 `_meta` 映射。 ## 验收标准 - ACP 不再读取或存储 `_meta.terminal_output` 能力状态。 -- `TerminalRendering`、终端 id、终端 cwd 解析以及 `_meta.terminal_*` update 映射从 `@deepseek-ai/dsh-acp` 中消失。 -- `ToolTerminal` 从 `@deepseek-ai/dsh-tools` 中消失,或在展示清理中因无使用而删除。 +- `TerminalRendering`、终端 id、终端 cwd 解析与 `_meta.terminal_*` update 映射从 `@deepseek-ai/dsh-acp` 中消失。 +- `ToolTerminal` 从 `@deepseek-ai/dsh-tools` 中消失,或在展示清理中因未使用而删除。 - Bash 结果展示不再为终端 pill 解析退出状态。 -- 已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 保留在 `implemented/` 中作为已交付的历史记录,如被本提案取代则互相交叉引用。 +- 已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 作为已交付历史保留在 `implemented/` 中;如被本提案取代,则加上交叉链接。 ## 放弃的内容 -Zed 用户将失去专属终端卡片:没有 cwd 头部、终端展示或 exit pill。他们仍能以普通内容形式看到命令和输出。在 ACP 桥接层尚未发布、`_meta` 键仍是约定而非标准的阶段,这是合理的简化。 +Zed 用户将失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。他们仍能以纯内容形式看到命令和输出。在 ACP 桥接层尚未发布、`_meta` 键只是约定而非标准的阶段,这是合理的简化。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml index f0ed7ad2b5..de324b8de2 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-bash-output-spill-files.md: bbc26c645cefb1659012ca0debda78fc6850d276 -2026-06-20-drop-bash-output-spill-files.zh.md: 1439616bf55ad388e69ba693cbc7c130f4e2fc8c +2026-06-20-drop-bash-output-spill-files.zh.md: 43b6029a68542d03027b61424a2a3024ace200d5 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md index 1439616bf5..43b6029a68 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md @@ -1,20 +1,20 @@ # RFC:移除 bash 完整输出溢出文件 -Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. - [English](2026-06-20-drop-bash-output-spill-files.md) | 中文 +Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. + ## 问题 -`dsh-bash-local` 在内存中保留有界的输出,并将大体积的 stdout/stderr 流溢出到私有临时文件。这要求维护一个私有目录、创建仅所有者可读的随机文件、处理关闭失败、按字节偏移增量读取、报告有损读取、在面向模型的文本中渲染路径,以及清理纪律。当输出被截断时,工具会告诉模型去读取一个本地溢出路径。 +`dsh-bash-local` 在内存中保留有界的输出,并将大体量的 stdout/stderr 流溢出到私有临时文件。这要求一个私有目录、仅所有者可写的随机文件创建、关闭失败处理、基于字节偏移的增量读取、有损读取报告、在面向模型的文本中渲染路径,以及清理纪律。当输出被截断时,该工具会告知模型去读取一个本地溢出路径。 -这解决了一个真实问题,但方式狭窄且有泄漏。溢出路径是一个暴露在模型输出中的进程本地文件系统产物,而非具备作用域访问、保留策略或 UI 能力的持久化 harness 产物。它还使后台任务的读取变得复杂,因为有损增量读取必须指向一到两个溢出文件。 +这解决了一个真实问题,但方式狭隘且有泄漏。溢出路径是一个暴露在模型输出中的进程级文件系统产物,而非具有作用域访问控制、保留策略或 UI 支持的持久化 harness 产物。它还使后台任务的读取变得复杂,因为有损增量读取必须指向一个或两个溢出文件。 ## 提案 -保留尾部截断,移除完整输出溢出文件。bash 结果包含有界的尾部内容加一个明确的截断标记;不输出路径。如果用户需要恢复完整输出,则添加一个通用的产物/blob 服务(具备显式的所有权、清理和 UI 渲染),再让 bash 将大体积输出附加到该服务。 +保留尾部截断,移除完整输出溢出文件。bash 结果包含有界的尾部内容加一个明确的截断标记;不输出路径。如果用户需要恢复完整输出,则添加一个通用的产物/blob 服务(具有明确的所有权、清理和 UI 渲染),然后让 bash 将大体量输出附加到该服务。 -本提案可以独立于[通用长时运行工具运行时](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md)落地。如果后台任务保留,`bash_output` 仍应报告输出已被丢弃,但不再公布溢出路径。 +本提案可以独立于[通用长时间运行工具运行时](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md)落地。如果后台任务保留,`bash_output` 仍应报告输出已被丢弃,但不再提供溢出路径。 ## 验收标准 @@ -24,8 +24,8 @@ Status: rejected — full-output recovery is a real bash behavior. A future arti - 测试覆盖尾部截断,不再断言完整输出文件的内容。 - [docs/defensive-patterns.md](../../../defensive-patterns.md) 中的安全指导不再将私有溢出文件视为面向模型的接口。 -## 放弃了什么 +## 放弃的能力 -模型或用户无法再从临时文件恢复大体积命令输出中被省略的前缀。在真正的产物服务出现之前,这是可接受的。当前的溢出路径为一个生命周期和权限都未经设计的功能引入了过多的定制机制。 +模型或用户无法再从临时文件恢复大体量命令输出中被省略的前缀。在真正的产物服务出现之前,这是可以接受的。当前的溢出路径为一个生命周期和权限均未经设计的功能引入了过多的定制机制。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml index 51d6027a24..7396bcf5aa 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-durable-step-boundaries.md: fba8ad4211db69d3a04d253fd9544caca0f538c6 -2026-06-20-drop-durable-step-boundaries.zh.md: 9ffa12cfba697f1e3bc9059529d0fdf97f595705 +2026-06-20-drop-durable-step-boundaries.zh.md: e389c5506b03a472c74853f3b73c21681f96287f diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md index 9ffa12cfba..e389c5506b 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -1,32 +1,32 @@ # RFC:移除持久化的步骤边界事件 +Status: rejected — `step/end` is the durable indication that a model step finished, and keeping the symmetric `step/start` / `step/end` pair makes crash repair, invariants, and transcript inspection clearer than inferring completion from adjacent step-scoped events. + [English](2026-06-20-drop-durable-step-boundaries.md) | 中文 -Status: rejected — `step/end` 是模型步骤已完成的持久化标识,保留对称的 `step/start` / `step/end` 对,在崩溃恢复、不变式检查和 transcript(文本记录)审查方面,都比从相邻的步骤级事件推断完成状态更清晰。 - ## 问题 -会话日志存储了 `step/start` 和 `step/end` 事件,尽管每个步骤级事件本身已携带 `{ turn, step }`:assistant 分片、assistant 消息、工具调用、工具结果、token 用量和错误。`deriveMessages()` 忽略步骤边界,ACP(Agent Client Protocol)在 UI 层也忽略它们,主要消费方是不变式检查、测试、快照 golden 文件和崩溃恢复。 +会话日志存储了 `step/start` 和 `step/end` 事件,尽管每个步骤作用域的事件本身已经携带 `{ turn, step }`:assistant 分片、assistant 消息、工具调用、工具结果、用量和错误。`deriveMessages()` 忽略步骤边界,ACP(Agent Client Protocol)在 UI 层面也忽略它们,主要消费方是不变式检查、测试、快照 golden 文件和崩溃恢复。 -被否决的论点是:边界事件让日志更像仪式而非信息。实际上,`step/end` 是具体信息:读者无需从下一个事件推导,就能判断一次模型请求是已完成、已崩溃还是正在被修复。同样,一条孤立的 `step/start` 对于「模型请求已发起但在产出任何分片之前就失败了」的场景也有用。 +被否决的论点是:边界事件使日志更像仪式而非信息。实际上,`step/end` 是具体信息:读者无需从下一个事件推导状态,就能判断一次模型请求是已完成、已崩溃还是正在修复。同样,一个孤立的 `step/start` 对于「模型请求已发起但在产生任何分片之前就失败了」的场景也有价值。 ## 提案 -以轮次作为唯一的持久化边界。从 `SessionEventMap` 中移除 `step/start` 和 `step/end`;保留步骤级事件上用于分组的数值 `step` 字段。agent loop(智能体循环)递增步骤计数器,并以该编号记录步骤级事件,但不再追加开/关边界事件。消费方通过共享 `(turn, step)` 的连续事件推断步骤分组。 +将轮次作为唯一的持久化边界。从 `SessionEventMap` 中移除 `step/start` 和 `step/end`;在需要分组的事件上保留数值型 `step` 字段。agent loop(智能体循环)递增步骤计数器并以该编号记录步骤作用域的事件,但不再追加开/关边界事件。消费方通过共享 `(turn, step)` 的连续事件推断步骤分组。 -不变式插件应强制步骤级事件在一个已打开的轮次内具有有效的正整数步骤编号,而非要求它们被独立的边界记录包围。崩溃恢复不应合成 `step/end`;如果一个被中断的轮次被保留,恢复路径仍可关闭该轮次而无需捏造步骤边界记录。 +不变式插件应当强制步骤作用域的事件在一个已打开的轮次内具有有效的正整数步骤编号,而非要求独立的边界记录包围它们。崩溃恢复不应合成 `step/end`;如果一个被中断的轮次被保留,修复路径仍然可以关闭该轮次而无需捏造步骤边界记录。 ## 验收标准 - `SessionEventMap` 不再包含 `step/start` 或 `step/end`。 -- agent loop 不再有 `closeStep()` 终结路径。 +- agent loop 中不再有 `closeStep()` 终结路径。 - ACP 快照和持久化契约 fixture(测试前置数据)不再期望步骤边界行。 -- `deriveMessages()` 和回放从步骤级事件推导出相同的消息历史。 -- [事件分类体系文档](../../../architecture.md)将轮次描述为持久化边界,将步骤描述为步骤级记录上的一个字段。 +- `deriveMessages()` 和回放从步骤作用域的事件推导出相同的消息历史。 +- [事件分类体系文档](../../../architecture.md)将轮次描述为持久化边界,将步骤描述为步骤作用域记录上的一个字段。 - 会话格式版本和已记录的 fixture 被刷新;按预发布格式策略,非当前版本的已存储日志被拒绝。 ## 放弃了什么 -日志不再将「一次模型请求已发起但进程在产出任何事件之前就终止了」记录为持久化事实,也不再有显式的「此步骤已完成」标记。在会话日志仍是持久化回放与审计表面的当下,这一损失不可接受。 +日志不再将「一次模型请求已发起但进程死亡前未产生任何事件」记录为持久化事实,也不再有显式的「此步骤已完成」标记。在会话日志仍是持久化回放与审计表面的当下,这一损失不可接受。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml index aeb154fbb1..f2aab138ee 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unused-session-lineage.md: 4200532726a27e257e09f927240846f20a0b30ad -2026-06-20-drop-unused-session-lineage.zh.md: c20f964317a1924c3cbc36b7f7838f3a207a65c1 +2026-06-20-drop-unused-session-lineage.zh.md: 1524987111f12a9c6e2014723bb1cb4c87bcf940 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md index c20f964317..1524987111 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md @@ -1,18 +1,18 @@ # RFC:移除未使用的会话血缘元数据 -Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. - [English](2026-06-20-drop-unused-session-lineage.md) | 中文 +Status: rejected — `parentSession` 是已文档化的 fork/subagent seam 的一部分,且已被 agent(智能体)/session 恢复路径保留。该字段面向未来,但并非意外的死状态。 + ## 问题 -`SessionHeader.parentSession` 记录新会话从哪个会话 fork 而来。它在 `dsh-session` 中定义,被持久化后端保存,在 resume 路径中被复制,作为血缘元数据被文档记录,并有往返测试覆盖。然而仓库中没有任何已上线的 fork UI 或 subagent 流程读取它。计划中的 subagent/fork seam 仍是一个 TODO,因此该字段目前只是被存储的未来形状。 +`SessionHeader.parentSession` 记录新会话从哪个会话 fork 而来。它在 `dsh-session` 中定义,被持久化后端保留,在恢复流程中复制,作为血缘元数据被文档记录,并有往返测试覆盖。然而仓库中没有任何生产环境的 fork UI 或 subagent 流程读取它。计划中的 subagent/fork seam 仍是 TODO,因此该字段目前只是预存的未来形状。 -单文件的代价虽小,但在整个格式中分布广泛:每个后端 schema 和元数据序列化器都在保存一个尚无已完成功能读取的值。由于 header 是一份磁盘契约,即便是占位字段也会成为未来重构必须维护、迁移或有意打破的东西。 +单个文件的成本虽小,但在格式层面影响面广:每个后端 schema 和元数据序列化器都在保留一个尚无已完成功能读取的值。由于 header 是磁盘契约,即使是占位字段也会成为未来重构必须维护、迁移或有意打破的东西。 ## 提案 -从 `SessionHeader` 中移除 `parentSession`,直到真正的 fork/resume 功能需要血缘信息时再引入。如果存在相应 API,fork 仍然可以用先前事件来初始化新会话,但持久化的父指针应当与读取它的功能和解释它的 UX 一同引入。 +从 `SessionHeader` 中移除 `parentSession`,直到真正的 fork/恢复功能需要血缘信息时再引入。如果存在相应 API,fork 仍然可以用先前事件来初始化新会话,但持久化的父指针应当与读取它的功能和解释它的 UX 一同引入。 如果血缘信息回归,届时再决定它应放在不可变 header 中、会话图索引中,还是作为一等事件。当前字段不应预先锁定那个设计。 @@ -20,12 +20,12 @@ Status: rejected — `parentSession` is part of the documented fork/sub-agent se - `SessionHeader` 仅包含 version、id、createdAt 和可选的 cwd。 - JSONL 与 SQLite 元数据 schema 不再存储 parent-session id。 -- resume 和 list API 不再往返传递 `parentSession`。 +- 恢复与列表 API 不再往返传递 `parentSession`。 - 文档和测试移除没有生产消费方支撑的 fork 血缘声明。 -- 会话格式版本、后端 schema 版本和录制的 fixture(测试前置数据)按需刷新;按预发布格式策略,非当前版本的已存储数据将被拒绝,不提供迁移路径。 +- 会话格式版本、后端 schema 版本与记录的 fixture(测试前置数据)按需刷新;按预发布格式策略,非当前版本的存储数据将被拒绝,不提供迁移路径。 ## 放弃了什么 -代码库失去一个为未来 fork/subagent UX 准备好的血缘钩子。这是有意为之。该字段在功能存在时很容易重新引入,而未发布的立场允许格式变更无需迁移。 +代码库失去了一个为未来 fork/subagent UX 预备的现成血缘钩子。这是有意为之。该字段在功能存在时很容易重新引入,而未发布的立场允许格式变更无需迁移。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml index bb1af91b9b..8442da7f7e 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-fold-session-persistence-interface.md: 695cd679c67f3e0a9f901c671c33e9507ecbe279 -2026-06-20-fold-session-persistence-interface.zh.md: 1ef93fb64cc27eeeb465136dc7dac6e751a7ccfc +2026-06-20-fold-session-persistence-interface.zh.md: 38f79f833fb5d95e4d9f392de627ee16b17cb997 diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md index 1ef93fb64c..38f79f833f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -1,4 +1,4 @@ -# RFC:将持久化接口合入 dsh-session +# RFC:将持久化接口合并进 dsh-session Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. @@ -6,26 +6,26 @@ Status: rejected — the separate persistence interface package is the intended ## 问题 -`dsh-session-persistence` 是一个接口包(package),其核心概念已由 `dsh-session` 拥有:`SessionHeader`、`SessionEvent`、`SessionId`、`session/event` 和 `session/flush`。该包额外引入了抽象的 `SessionPersistence` 服务、共享写协调器和契约辅助工具。后端包依赖它,`agent-loop` 则需要可选地发现一个兄弟服务来实现恢复。 +`dsh-session-persistence` 是一个接口包(package),其核心概念已经由 `dsh-session` 拥有:`SessionHeader`、`SessionEvent`、`SessionId`、`session/event` 与 `session/flush`。该包额外添加了抽象的 `SessionPersistence` 服务、共享写入协调器和契约辅助工具。后端包依赖它,`agent-loop`(智能体循环)也需要可选地查找一个同级服务来实现恢复。 -当持久化还是一个全新的可替换后端设计时,能力 seam 的拆分是合理的。但在可变摘要被移除之后,这个接口包基本上只是包装了会话日志自身的存储关注点。保持独立可能带来的仪式感多于清晰度。 +当持久化还是一个全新的可替换后端设计时,能力 seam 的拆分是合理的。但在可变摘要被移除之后,这个接口包基本上只是包装了会话日志自身的存储关切。继续保持独立可能带来的仪式感多于清晰度。 ## 提案 将抽象的 `SessionPersistence` 服务、协调器和持久化契约辅助工具移入 `dsh-session`。JSONL 和 SQLite 仍作为独立的后端包,注册由 session 包拥有的服务。这样既保留了后端可替换性,又删除了一个支撑包和一条跨包 seam。 -实施 PR(Pull Request)应更新[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md) 指南,补充此例外:持久化不同于 bash 或 LLM(大语言模型),因为它的词汇和生命周期事件本身就是 session 包的核心领域。 +实施 PR(Pull Request)应更新[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md) 指南,补充此例外:持久化不同于 bash 或 LLM(大语言模型),因为它的词汇和生命周期事件本就属于 session 包的核心领域。 ## 验收标准 - `@deepseek-ai/dsh-session-persistence` 作为包被移除。 - `dsh-session` 导出持久化服务类型、协调器和契约辅助工具。 - JSONL 和 SQLite 后端包直接依赖 `dsh-session`。 -- `agent-loop` 的恢复功能使用由 session 包拥有的服务键。 -- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)、[共享持久化写协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)与[包文档](../../../../packages/session-persistence/session-persistence/README.md)说明后端实现为何仍保持独立。 +- `agent-loop` 的恢复功能使用 session 包拥有的服务键。 +- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)、[共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)与[包文档](../../../../packages/session-persistence/session-persistence/README.md)说明后端实现为何仍保持独立。 ## 放弃了什么 -`dsh-session` 变得更重:它同时拥有内存日志和持久化接口。这就是取舍。如果第三方持久化后端已经形成公开生态,独立的接口包会是更清晰的 SDK 边界;但在预发布阶段,多出的包看起来像是在有外部消费方之前的过度抽象。 +`dsh-session` 变得更重:它同时拥有内存日志和持久化接口。这就是代价。如果第三方持久化后端已经形成公开生态,独立的接口包会是更清晰的 SDK 边界;但在预发布阶段,在尚无外部消费方时,多出的包看起来更像是过早的抽象。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml index db3ac5d979..3af32eacdd 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generic-tool-rendering.md: 07102ed3b12d7f587b1d12f0e240c1202a2e1cdd -2026-06-20-generic-tool-rendering.zh.md: 86ea52545a79ff975fc6b9c5d080e111a2750290 +2026-06-20-generic-tool-rendering.zh.md: d2c8c745f0ac04a01eb72b50fd1b63cb655afe36 diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md index 86ea52545a..d2c8c745f0 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -6,30 +6,30 @@ Status: rejected — tool-owned presentation should wait for more real tools bef ## 问题 -工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身已标记出设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步堆积成一堆可选字段。ACP(Agent Client Protocol)随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从已渲染的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 +工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步增长为一堆可选字段。ACP(Agent Client Protocol)随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 -真正的一方使用场景是 ACP 的 bash 展示。这不足以作为冻结一个跨包 UI 展示 API 的依据。 +真正的第一方用途是为 ACP 提供 bash 展示。这不足以作为冻结一个跨包 UI 展示 API 的依据。 ## 提案 -暂时移除工具自有的 UI 展示回调。规范的工具事件已经携带工具名称、原始参数字符串、结果内容与错误状态。UI 从这些字段渲染一个通用的工具卡片。工具特有的富展示可以在至少有两个真实工具和两个真实消费方来验证词汇后,以 tagged render-intent union 的形式回归。 +暂时移除工具自有的 UI 展示回调。规范的工具事件已经携带工具名、原始参数字符串、结果内容和错误状态。UI 从这些字段渲染一个通用的工具卡片。工具特有的富展示可以在至少有两个真实工具和两个真实消费方来验证词汇之后,以带标签的 render-intent union 形式回归。 ## 曾考虑的替代方案 -一个更小的替代方案是在单个 PR(Pull Request)中将当前的可选字段包替换为一个显式 union;但如果目标是简化,更彻底的做法是删除回调、保留通用路径。 +作为更小的替代方案,可以在一个 PR(Pull Request)中将当前的可选字段集合替换为一个显式 union;但如果目标是简化,更彻底的做法是删除回调、保留通用路径。 ## 验收标准 - `ToolDefinition` 移除 `presentCall` 和 `presentResult`。 - `ToolCallPresentation`、`ToolResultPresentation`、`ToolTerminal` 和 `ToolCallKind` 消失,除非一个最小的通用 UI 类型仍需要其中之一。 -- ACP 不再维护 presenter pending 状态,也不在实时流式输出/加载回放期间调用工具回调。 -- `dsh-tool-bash` 不再解析已渲染文本来恢复退出状态以生成 UI pill。 -- 快照 golden 展示通用工具卡片和文本结果。 +- ACP 不再维护 presenter pending 状态,也不再在实时流式输出/加载回放期间调用工具回调。 +- `dsh-tool-bash` 不再解析渲染文本来恢复退出状态以供 UI pill 使用。 +- 快照 golden 文件展示通用工具卡片和文本结果。 ## 放弃了什么 -Bash 失去其自定义的终端风格卡片和模型撰写的描述位置。回退方案仍然合理:命令作为工具输入展示,输出作为文本展示。富展示应在产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时再行设计。 +Bash 失去其自定义的终端风格卡片和模型生成描述的放置位置。回退方案仍然合理:命令作为工具输入展示,输出作为文本展示。富展示应当在产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时再行设计。 ## 相关 -本 RFC 是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 RFC 被接受,那个更窄的 RFC 就不再需要。 +这是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 RFC 被接受,那个更窄的 RFC 就不再必要。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml index 62eb41dc9a..a944072e9f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-retire-mid-turn-steering.md: bf78125ec175aa9152789bdccd1f8e8a16863a5b -2026-06-20-retire-mid-turn-steering.zh.md: 03af2b24916a5d1a479dc06a30d54003dfa7f156 +2026-06-20-retire-mid-turn-steering.zh.md: a56e112df37bdeb75ca808f76beabe1fec8b1b7b diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md index 03af2b2491..a56e112df3 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md @@ -1,37 +1,37 @@ -# RFC:废除中途 steering(中途引导) - -Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. +# RFC:移除轮次中途引导 [English](2026-06-20-retire-mid-turn-steering.md) | 中文 +Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. + ## 问题 -agent(智能体)暴露了两条用户消息路径,看起来相近但生命周期语义不同:`send()` 将一条普通用户轮次排入队列,而 `steer()` 在当前运行轮次的步骤之间注入一条消息,空闲时则回退为 `send()`。这一区别渗透到整个栈:`Agent.steer()` 是公开 API,会话日志有持久化的 `steering/message` 事件,agent 事件分类体系有 `agent/steering`,循环在排队消息 FIFO 之外还维护一个 steering FIFO,取消操作需要清空两个队列,`deriveMessages()` 必须将 steering 渲染为带标签的合成用户消息而非普通提示词。 +agent(智能体)暴露了两条用户消息路径,外观相近但生命周期语义不同:`send()` 将一条普通用户轮次排入队列,而 `steer()` 在当前运行轮次的步骤之间注入一条消息,空闲时则回退为 `send()`。这一区分贯穿整个栈:`Agent.steer()` 是公开 API;会话日志有持久化的 `steering/message` 事件;agent 事件分类体系有 `agent/steering`;agent loop(智能体循环)在排队消息 FIFO 之外还维护一个 steering FIFO;取消操作需要清空两个队列;`deriveMessages()` 必须将 steering 渲染为带标签的合成用户消息,而非普通提示词。 -continuation seam 放大了这一成本。`agent/turn-continuation` 默认为 `hadToolCalls || steeringInjected`,因此同一轮次内的 steering 消息即使模型没有请求工具调用也能强制循环再次调用模型。注释中提到了未来的 `/goal`、`/loop` 和预算守卫用途,但当前仓库没有生产级监听器;只有测试注册了该 waterfall(瀑布式事件)。另外,唯一调用 `steer()` 的生产 UI 是 stdio 演示。ACP 在轮次运行期间已经通过普通队列发送提示词。 +续行 seam 进一步放大了成本。`agent/turn-continuation` 默认条件为 `hadToolCalls || steeringInjected`,因此同一轮次内的 steering(中途引导)消息即使模型未请求工具调用,也会强制循环再次调用模型。注释中提到了未来 `/goal`、`/loop` 和预算守卫的用途,但当前仓库没有生产级监听器;只有测试注册了该 waterfall(瀑布式事件)。另外,唯一调用 `steer()` 的生产 UI 是 stdio 演示。ACP(Agent Client Protocol)在轮次运行期间已经通过普通队列发送提示词。 ## 提案 -暂时删除中途用户 steering。`Agent.send()` 成为提交用户内容的唯一公开方式;当 agent 正在运行时,内容等待下一轮次。循环仅因工具调用而在轮次内继续,而非因为用户在某步骤运行期间输入了内容。想要中断当前轮次的调用方使用 `cancel()` 再 `send()`。 +暂时删除轮次中途的用户 steering。`Agent.send()` 成为提交用户内容的唯一公开方式;当 agent 正在运行时,内容等待下一个轮次。循环仅因工具调用而在轮次内继续,不因用户在某个步骤运行期间输入内容而继续。调用方若要中断当前轮次,使用 `cancel()` 后再 `send()`。 -移除 `Agent.steer()`、steering FIFO、`steering/message`、`agent/steering`、由 steering 驱动的 continuation,以及区分排队消息与 steering 消息的取消逻辑。在同一变更中移除 `agent/turn-continuation`,除非实现 PR 发现了生产级监听器;没有 steering 之后,当前仓库不再有具体的 continuation 消费方。如果将来真正的预算或 goal 插件需要强制 continuation,应以该插件为具体消费方重新引入一个更窄的 seam。 +移除 `Agent.steer()`、steering FIFO、`steering/message`、`agent/steering`、由 steering 驱动的续行逻辑,以及取消操作中区分排队消息与 steering 消息的逻辑。除非实现 PR 发现了生产级监听器,否则在同一变更中一并移除 `agent/turn-continuation`;没有 steering 后,当前仓库不再有具体的续行消费方。如果将来真正的预算或目标插件需要强制续行,应以该插件为具体消费方重新引入一个更窄的 seam。 ## 验收标准 - `Agent` 暴露唯一的用户消息入口 `send()`。 - 持久化会话事件词汇不再包含 `steering/message`。 -- `deriveMessages()` 渲染普通用户消息和上下文注入,不再有 steering 标签路径。 -- 循环只有一个排队消息 FIFO,没有同轮次用户消息 continuation 路径。 -- `agent/turn-continuation` 被移除或收窄到一个具名的生产级消费方。 -- stdio UI 和文档将运行期间的输入描述为排入下一轮次的输入。 -- 会话格式版本和录制的 fixture(测试前置数据)已刷新;按预发布格式策略拒绝非当前版本的存储日志。 +- `deriveMessages()` 渲染普通用户消息和上下文注入,不存在 steering 标签路径。 +- 循环只有一个排队消息 FIFO,没有同轮次用户消息续行路径。 +- `agent/turn-continuation` 被移除,或收窄到有具名的生产级消费方。 +- stdio UI 和文档将运行期间的输入描述为「排入下一轮次的输入」。 +- 会话格式版本和已录制的 fixture(测试前置数据)已刷新;非当前版本的存储日志按预发布格式策略被拒绝。 ## 放弃了什么 -用户无法在模型处于工具步骤之间时添加同轮次 steering 内容。这种行为在理论上对「你已经在工作了,顺便也考虑一下 X」有用,但它不是 ACP 今天暴露的行为,而且它使轮次边界变得更难推理。更简单的行为是合理的:用户输入成为下一条提示词,取消仍是替换进行中工作的显式手段。 +用户无法在模型处于工具步骤之间时添加同轮次 steering 内容。这种行为在理论上对「你已经在工作了,也考虑一下 X」的场景有用,但它不是 ACP 当前暴露的行为,且使轮次边界更难推理。更简单的行为是合理的:用户输入成为下一条提示词,取消操作仍是替换进行中工作的显式手段。 ## 相关 -本提案与[删除持久化步骤边界](2026-06-20-drop-durable-step-boundaries.md)天然配对,因为移除同轮次 steering 和 `agent/turn-continuation` 之后,工具调用成为一个轮次包含多个模型步骤的唯一原因。 +本提案与[移除持久化步骤边界](2026-06-20-drop-durable-step-boundaries.md)天然配对,因为移除同轮次 steering 和 `agent/turn-continuation` 后,工具调用成为一个轮次包含多个模型步骤的唯一原因。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml index aa8c2bbf58..57044817c0 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-single-session-acp-bridge.md: b7b52ca4df2d118358303745f4484e3e40a242b3 -2026-06-20-single-session-acp-bridge.zh.md: 2f00067fffcbd7a74f403c6247b290ac09a846b8 +2026-06-20-single-session-acp-bridge.zh.md: bd287f79475433e4d5e502ef30abd5d14410e633 diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md index 2f00067fff..bd287f7947 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md @@ -1,4 +1,4 @@ -# RFC:将 ACP 桥接恢复为每连接单会话 +# RFC:将 ACP 桥接恢复为每连接一个活跃会话 [English](2026-06-20-single-session-acp-bridge.md) | 中文 @@ -6,21 +6,21 @@ Status: rejected — Zed 是当前目标 ACP 客户端,其 ACP 实现明确支 ## 问题 -ACP 桥接现已支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向的会话/agent 查找、逐会话的提示词状态、加载中 id、每个事件的解复用、跨会话的销毁,以及未来权限提示与后台任务的隔离问题。早先的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在跟踪未完成的权限归属部分;本 RFC 是与之竞争的简化路径。 +ACP(Agent Client Protocol)桥接现在支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向会话/agent(智能体)查找、逐会话的 prompt 状态、加载中 id、每条事件的解复用、跨会话拆除,以及未来权限提示与后台任务的隔离问题。较早的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在追踪未完成的权限归属部分;本 RFC 是与之竞争的简化路径。 -产品目标已证明它需要在一个 harness 进程上承载并发的编辑器对话:Zed 的 ACP 连接拥有多个会话和加载状态。快照回放层仍然避免并发模型流,因为其回放条目是位置敏感的;这是测试 fixture 的局限,不是移除桥接多路复用的理由。 +产品目标已经证明它需要在一个 harness 进程上承载并发的编辑器对话:Zed 的 ACP 连接拥有多个会话和加载状态。快照回放层仍然避免并发模型流,因为其回放条目是位置相关的;这是测试 fixture(测试前置数据)的局限,而非移除桥接多路复用的理由。 ## 提案 -将 ACP 的作用域收回到每连接一个活跃会话。`session/new` 或 `session/load` 创建唯一的会话记录;在现有会话被 dispose 或连接关闭之前,第二个活跃会话请求将被拒绝。如果编辑器需要多个聊天标签页,可以启动多个 agent 子进程,直到桥接具备具体的多会话 UX 和权限模型。 +将 ACP 的范围收回到每连接一个活跃会话。`session/new` 或 `session/load` 创建唯一的会话记录;在现有会话被 dispose(资源释放)或连接关闭之前,第二个活跃会话请求将被拒绝。如果编辑器需要多个聊天标签页,可以启动多个 agent 子进程,直到桥接具备具体的多会话 UX 和权限模型。 -在单个 `SessionRecord | undefined` 即可满足需求的地方,移除多会话映射和解复用逻辑。桥接仍可保留使销毁行为正确的 agent/会话生命周期 seam;简化仅针对在同一传输层上多路复用多个活跃会话这一点。 +移除多会话映射和解复用逻辑,改用单一的 `SessionRecord | undefined` 即可。桥接仍可保留使 dispose 正确的 agent/会话生命周期 seam;简化仅针对在同一传输层上多路复用多个活跃会话这一点。 ## 验收标准 -- ACP 每连接仅有一条活跃会话记录。 +- ACP 每连接只有一条活跃会话记录。 - 当该记录存在时,`session/new` 和 `session/load` 拒绝请求。 -- 事件处理器不再跨 `Map<sessionId, record>` 解复用。 +- 事件处理器不再在 `Map<sessionId, record>` 上做解复用。 - 多会话测试被移除,或移至继续支持多路复用的提案下。 - 既有的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)更新为链接本 RFC,并继续作为当前方向。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index 32aca9d1e8..19a66353c7 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-truncate-interrupted-turns.md: e17cb20f0185fe5d47d0a5ca18b7a389951fbadd -2026-06-20-truncate-interrupted-turns.zh.md: 9ded51b29323be452bd67901dea2ba7b309a9903 +2026-06-20-truncate-interrupted-turns.zh.md: 48aeae650f867fb4629db33a448dd6cbaea60ae0 diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index 9ded51b293..48aeae650f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -1,4 +1,4 @@ -# RFC:加载时截断被中断的末尾轮次 +# RFC:加载时截断被中断的最终轮次 Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. @@ -6,31 +6,31 @@ Status: rejected — a single turn can contain substantial real work, including ## 问题 -当前的持久化契约会保留最后一个已持久写入但从未关闭的轮次。加载时,`interruptedTurnClosers()` 扫描尾部,为未应答的工具调用合成错误的 `tool/result` 事件,在步骤未关闭时追加 `step/end`,追加 `turn/end { kind: 'interrupted' }`,并要求后端持久提交这段修复。协调器、JSONL 后端、SQLite 后端、会话事件词汇、不变式、文档和测试都对这条合成关闭路径建了模。 +当前的持久化契约会保留已持久写入但从未关闭的最终轮次。加载时,`interruptedTurnClosers()` 扫描尾部,为未应答的工具调用合成 error `tool/result` 事件,在 step 处于打开状态时追加 `step/end`,追加 `turn/end { kind: 'interrupted' }`,并要求后端持久提交这次修复。协调器、JSONL 后端、SQLite 后端、会话事件词汇、不变式、文档和测试都对这条合成关闭路径进行了建模。 -这是为了保留上一次崩溃轮次的部分工作而引入的大量机制。它还会生造从未发生过的事件。合成的工具结果有用处(它使提供方历史保持合法),但也意味着恢复后的日志中包含了没有任何工具产出过的、模型可见的文本。当前设计在尚无已发布产品、也没有真实的恢复 UX 来证明部分轮次恢复确有价值的情况下,就优化了最大化的尾部保留。 +这是一套庞大的机制,只为保留上次崩溃轮次中的部分工作。它还会凭空创造从未发生过的事件。合成的工具结果虽然有用(因为它使 provider 历史保持合法),但也意味着恢复后的日志中包含了模型可见、却并非任何工具产出的文本。当前设计在尚无已发布产品、也没有真实恢复 UX 来证明部分轮次恢复确有价值的情况下,就优化了最大化尾部保留。 ## 提案 -加载时只保留到最后一个已完成的轮次。后端仍然容忍并截断撕裂的末尾记录,但如果解析出的持久前缀在一个已打开的 `turn/start` 之后结束,规范的修复方式是丢弃上一个 `turn/end` 之后的所有事件。不合成 `tool/result`,不合成 `step/end`,不追加 `turn/end { interrupted }`,也不需要 `interrupted` 轮次结束原因。 +加载时只保留最后一个已完成的轮次。后端仍然容忍并截断撕裂的最终记录,但如果解析出的持久前缀止于一个打开的 `turn/start` 之后,规范的修复方式是丢弃上一个 `turn/end` 之后的所有事件。不合成 `tool/result`,不合成 `step/end`,不追加 `turn/end { interrupted }`,也不引入 `interrupted` 轮次结束原因。 -这使持久化的轮次边界变得简单:一个已完成的 `turn/end` 就是检查点。最后一个检查点之后的内容都是崩溃尾部。下一次提示词从最后一个已知合法的提供方 transcript 恢复,而非从部分重建的末尾轮次恢复。 +这使持久化的轮次边界变得简单:一个已完成的 `turn/end` 就是检查点。最后一个检查点之后的内容都是崩溃尾部。下一次 prompt 从最后一个已知合法的 provider transcript(文本记录)恢复,而不是从部分重建的最终轮次恢复。 ## 验收标准 - `TurnEndReasonMap` 移除 `interrupted` 变体。 -- `interruptedTurnClosers()` 及其测试消失。 -- 持久化协调器的修复钩子截断后端特定的撕裂/未关闭尾部状态,不追加关闭事件。 -- [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)说明加载返回到最后一个已完成轮次,不包含部分末尾轮次。 -- 快照测试与契约测试随其所固定的行为一起更新。 -- 会话格式版本与已记录的 fixture(测试前置数据)一并刷新;按预发布格式策略,非当前版本的存储日志被拒绝,不提供迁移路径。 +- `interruptedTurnClosers()` 及其测试删除。 +- 持久化协调器的修复钩子截断后端特有的撕裂/打开尾部状态,不追加关闭事件。 +- [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)说明加载返回最后一个已完成的轮次,不包含部分最终轮次。 +- 快照与契约测试随其所固定的行为一同更新。 +- 会话格式版本与记录的 fixture(测试前置数据)刷新;按预发布格式策略,非当前版本的存储日志被拒绝,不提供迁移路径。 -## 放弃了什么 +## 放弃的内容 -一次崩溃可能丢失末尾轮次中的真实工作:上一个 `turn/end` 之后追加的助手文本、工具调用和工具输出。这是有意为之的简化。产品尚未发布,末尾轮次恢复的语义未经用户验证,而一个干净的「已完成轮次即检查点」模型在解释、测试和实现上都容易得多。未来如果需要「恢复部分崩溃工作」功能,应当设计为一个面向用户的显式恢复视图,而非静默插入规范 transcript 的合成事件。 +崩溃可能丢失最终轮次中的真实工作:上一个 `turn/end` 之后追加的助手文本、工具调用和工具输出。这是有意为之的简化。产品尚未发布,最终轮次恢复的语义未经用户验证,而一个干净的「已完成轮次即检查点」模型在解释、测试和实现上都容易得多。未来若需「恢复部分崩溃工作」功能,应设计为面向用户的显式恢复视图,而非静默插入规范 transcript 的合成事件。 ## 相关 -本 RFC 是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)和[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化步骤边界事件的大部分动机,使 [drop durable step boundary events](2026-06-20-drop-durable-step-boundaries.md) 的变更范围更小。 +本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化 step 边界事件的大部分动机,使[移除持久化 step 边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index c61b774371..915d56d0e8 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-unimplemented-subagent-vocabulary.md: 3c86f11564d85b423fe59d784c6bf69959fb3907 -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: ad8c8541ca682be6e71b6fe4ae166c3b65d14cdf +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 84ff6f15ff2c9b3a13240997ab3c7b5cf2ab7263 diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index ad8c8541ca..84ff6f15ff 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -1,39 +1,39 @@ -# RFC:裁剪 subagent seam 中未实现的词汇 - -Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. +# RFC:裁剪未实现的 subagent seam 词汇 [English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 +Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. + ## 问题 -[subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:由服务在启动时检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三项启动时特性和两个可选运行时方法均无实现、无调用方: +[subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:启动时由服务检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三个启动时特性和两个可选运行时方法的实现数与调用数均为零: -- **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构建 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两项;`structured` 仅由测试 mock(`packages/support/subagent-mock`)为其自身 spec 产出。服务的能力检查包含两行 assert,唯一的执行者是拒绝测试。 -- **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——连 mock 也没有;spawn spec 断言的是它们的*缺席*。 +- **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅由测试 mock(`packages/support/subagent-mock`)为其自身 spec 产出。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 +- **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——包括 mock 也没有;spawn spec 断言的正是它们的*缺失*。 -`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 `SchemaSpec` 类型。三个后续 subagent 工作流(per-session 快照回放、fork seed 边界、ACP 后端)都围绕这块表面落地,却没有增长出哪怕一个消费方。 +`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 `SchemaSpec` 类型。三个后续 subagent 工作流(per-session 快照回放、fork seed 边界、ACP(Agent Client Protocol) 后端)都围绕这块接口面落地,却没有增长出哪怕一个消费方。 ## 提案 -从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 和 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、mock 的 structured 分支及其 `capabilities`/`structured` 配置旋钮,以及为固定被移除表面而存在的测试(两行拒绝测试、spawn 缺席测试、mock structured spec)。从 `packages/subagent/subagent/package.json` 中删除 `dsh-tools` 的 peer/dev 依赖。更新 [subagent.md](../../../core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest,以及 `packages/subagent/subagent`、`packages/subagent/subagent-spawn`、`packages/subagent/subagent-fork` 和 `packages/support/subagent-mock` 的 README 相关行。实现 PR 按 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam RFC 的能力目录。 +从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 与 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、mock 的 structured 分支及其 `capabilities`/`structured` 配置项,以及为固定被移除接口面而存在的测试(两行拒绝测试、spawn 缺失测试、mock structured spec)。从 `packages/subagent/subagent/package.json` 中删除 `dsh-tools` 的 peer/dev 依赖。更新 [subagent.md](../../../core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest(元数据清单),以及 `packages/subagent/subagent`、`packages/subagent/subagent-spawn`、`packages/subagent/subagent-fork` 和 `packages/support/subagent-mock` 的 README 相关行。实现 PR(Pull Request)按照 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam RFC 的能力目录。 -**保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是补上工具默认值,而非删除正在工作的强制逻辑。 +**保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的 tool 尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个 tool 默认值,而非删除正在工作的强制逻辑。 -审视过但有意不动的相邻表面:`SubagentService.getProvider()`/`list()` 只有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 记录了完全相同的形态曾从 bash executor 中移除后又被回退——测试 harness 对于一个在已跟踪 map 上的单行访问器而言就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) 的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent 唯一的最终消息通道);它当前未接通的桥接转发是一个待弥合的缺口或待记录的消费方,不是本 RFC 要裁剪的表面。 +审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 恰好记录了这种形态从 bash executor 中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) 的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 RFC 要裁剪的接口面。 -这是 [从持久化 seam 裁剪死方法](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 在 seam 词汇层面的回声:每个实现都必须为无人声明的成员——甚至更弱,因为这里连一个实现都不存在。 +这是[从持久化 seam 裁剪死方法](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须为无人声明的成员,甚至更弱,因为这里连一个实现都没有。 ## 曾考虑的替代方案 ### 为什么不保留? -两类能力的设计是 seam RFC 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 RFC 作为记录仍然成立;而且 seam RFC 本身承认已交付的 `toolFilter` 形态是错的(真正的强制需要在子 agent 的上下文中设置 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此面向真实实现提供方重新添加时,将固定一份比当前推测性契约更好的契约。 +两类能力的设计是 seam RFC 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 RFC 作为记录仍然成立;而且 seam RFC 本身承认已交付的 `toolFilter` 形态是错误的(真正的强制需要在子 agent 上下文中实施 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此基于真实实现提供方重新添加时,将固定出一份比当前推测性契约更好的契约。 ## 验收标准 -- 被移除的拼写仅出现在本 RFC 和修订后的 seam RFC 中;`SubagentCapabilities` 为 `{ depthLimit: boolean }`;`dsh-tools` 依赖边已消除(`hygiene` 绿)。 -- 深度强制测试不变且绿。 +- 被移除的拼写仅出现在本 RFC 和修订后的 seam RFC 中;`SubagentCapabilities` 为 `{ depthLimit: boolean }`;`dsh-tools` 依赖边已消除(`hygiene` 绿色)。 +- 深度强制测试不变且绿色。 ## 风险 -subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 RFC 缩减的 seam 词汇范围内;observe-enrich RFC 记录了因缺乏消费方而删除 `agentType` 兄弟字段的判断:本 RFC 延续的正是这一判断。CC hooks 桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不触及本文移除的任何表面;observe-enrich RFC 中延期的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 RFC 模式所预期的重新添加触发点。 +subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 RFC 缩减的 seam 词汇范围内;observe-enrich RFC 记录了因缺少消费方而删除 `agentType` 兄弟字段的判断,本 RFC 延续了这一判断。CC hooks 桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不涉及本文移除的任何接口面;observe-enrich RFC 推迟的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 RFC 模式所预期的重新添加触发点。 diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 96bb401556..98bc1e4bab 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-collapse-workflow-to-foreground-core.md: 78b67c10ac39fddaf4ea76ca90d5cbf760fe5866 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 567da63e86b24dbedfd6fb50da0984c9866bd9cb +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 28b0af0a70110d39572fafac521a555c62ebb3f5 diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 567da63e86..28b0af0a70 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -1,39 +1,39 @@ -# RFC:将工作流收缩至实际使用的前台核心 - -Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. +# RFC:将工作流收缩至已使用的前台核心 [English](2026-07-12-collapse-workflow-to-foreground-core.md) | 中文 +Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. + ## 问题 -工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 仍定义了 run/phase/agent outcome 载荷,worker 仍发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 的唯一目的就是关联这些通知。 +工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 -这套进度词汇不仅未被使用,而且在不重新设计的情况下无法服务于它唯一的具名未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具从不暴露 run id。一个全局 ACP 监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不会对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 只喂给事件,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 +这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent(智能体)、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 -live handle 在观察者消失后仍重复事件时代的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 +live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 -取消也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有消除任何竞态。 +取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。 -`WorkflowError.fatal` 是同类投机分支的微缩版:每个生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 +`WorkflowError.fatal` 是同一种推测性分支的微缩版:所有生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 ## 提案 -保留实际使用的核心:`agent(prompt, { schema, model })`、`parallel`、`pipeline`、`args`、并发/agent 上限、取消、有界 dispose、结构化结果、worker 隔离,以及前台工具收集。移除所有 `workflow/*` 事件及其仅服务于事件的 info/outcome 类型;移除 `phase()`、`log()`、agent 的 `label`/`phase`、phase 声明、`whenToUse` 及其 worker 消息/host 观察者;将工作流元数据收缩为工具实际使用的 name;移除仅服务于事件的 run id/meta 快照以及合成的 agent-end 账本。将 `WorkflowRun` 收缩为 `result`、`cancel()` 和 `dispose()`;工具渲染请求方持有的 name。移除 `WorkflowStartRequest.signal` 及 worker host 的 input-signal listener/disarm 状态,保留调用方从自身 abort signal 到 `run.cancel()` 的桥接。将 `WorkflowError` 变为单一的 fatal 错误类,不再有布尔模式或 `isFatalWorkflowError()` 辅助函数。 +保留已使用的核心:`agent(prompt, { schema, model })`、`parallel`、`pipeline`、`args`、并发/agent 上限、取消、有界 dispose(资源释放)、结构化结果、worker 隔离与前台工具收集。移除所有 `workflow/*` 事件及其仅供事件使用的 info/outcome 类型;移除 `phase()`、`log()`、agent 的 `label`/`phase`、phase 声明、`whenToUse` 及其 worker 消息/host 观测者;将工作流元数据收缩为工具实际使用的 name;移除仅供事件使用的 run id/meta 快照与合成的 agent-end 账本。将 `WorkflowRun` 收缩为 `result`、`cancel()` 和 `dispose()`;工具渲染请求方持有的 name。移除 `WorkflowStartRequest.signal` 及 worker host 的 input-signal listener/disarm 状态,保留调用方从其 abort signal 到 `run.cancel()` 的桥接。将 `WorkflowError` 变为单一的 fatal 错误类,不再有布尔模式或 `isFatalWorkflowError()` 辅助函数。 -修订已实施的动态工作流 RFC,并更新 seam/tool/worker README、工具 schema、生成的 catalog 与包依赖图、worker type-equiv 记录、单元测试,以及工作流快照/header fixture。如果未来委托进度 UI 工作,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活此协议。 +修订已实施的 dynamic-workflow RFC,并更新 seam/tool/worker README、工具 schema、生成的 catalog 与 package 依赖图、worker type-equiv 记录、单元测试以及工作流快照/header fixture(测试前置数据)。如果进度 UI 工作被立项,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活这套协议。 ## 曾考虑的替代方案 -**为未来 UI 保留预建的观测词汇。** 当前形状类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或合成的终端 end 配对。移除它意味着放弃形状兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍然缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让具名的 ACP 消费方可行。 +**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的 dynamic-workflow 元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 ## 验收标准 - 工作流公开 seam 仅包含有生产消费方的执行、取消、结果与 dispose 契约。 -- 不再保留任何工作流事件、phase/log 协议消息、run-id 生成器、仅服务于进度的元数据、host 配对账本或 fatal 模式分支。 +- 不再保留任何工作流事件、phase/log 协议消息、run-id 生成器、仅供进度使用的元数据、host 配对账本或 fatal 模式分支。 - run handle 不再有 id/meta 回显,取消在同步 `start()` 返回后只有一条持有者拥有的通道。 -- parallel/pipeline 行为、上限、取消静默、worker 隔离、结构化输出以及面向模型的工作流场景保持覆盖率。 +- parallel/pipeline 行为、上限、取消静默、worker 隔离、结构化输出与面向模型的工作流场景保持测试覆盖。 - 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 ## 风险 -这是对工作流 DSL、事件分类体系、handle 与 start request 的编译可见收缩。现有提供描述性元数据的工作流调用,以及使用 `phase`、`log` 或 label 的脚本,必须相应精简;程序化调用方需自行将 abort 源桥接到返回的 handle;未来的观察者必须添加一个关联性更好的 seam。使工作流真正有用的执行语义不变。 +这是对工作流 DSL、事件分类体系、handle 与 start request 的编译可见收缩。现有提供描述性元数据的工作流调用,以及使用 `phase`、`log` 或 label 的脚本,都必须相应精简;程序化调用方需自行将 abort source 桥接到返回的 handle;未来的观测者必须添加一个关联性更好的 seam。使工作流有用的执行语义不变。 diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml index 1fb7c5343a..a9c24674bf 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-prune-unused-skill-registry-surface.md: 3e8c009871c3d609612b4a01edc2048ddedfad0d -2026-07-12-prune-unused-skill-registry-surface.zh.md: 1412e3cfac1adbb8b563ad50edb08c53a719feb8 +2026-07-12-prune-unused-skill-registry-surface.zh.md: deaea2ca53d4ed2ac5141013f969f621d3202875 diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md index 1412e3cfac..deaea2ca53 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md @@ -1,29 +1,29 @@ -# RFC:裁剪未使用的 skill 注册表接口 - -Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. +# RFC:裁剪 skill 注册表中未使用的接口 [English](2026-07-12-prune-unused-skill-registry-surface.md) | 中文 +Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. + ## 问题 -skill 服务的嵌入式运行时子系统没有任何生产调用方调用 `ctx.skills.register()`。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose 函数和测试,而这些都与每个已交付 skill 实际使用的提供方 seam 并行存在。`SkillSummary.whenToUse` 以及 candidate/definition 上的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位符。刻意开放的 `metadata` 扩展点保留不动。 +skill(技能)服务的嵌入式运行时子系统中,`ctx.skills.register()` 没有任何生产调用方。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose(资源释放)器以及相应测试——而所有已交付的 skill 都只使用提供方 seam。`SkillSummary.whenToUse` 和 candidate/definition 的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位器。有意开放的 `metadata` 扩展点保留不动。 ## 提案 -移除 `SkillService.register()`、`SkillRegistration`、运行时伪提供方及保留名称规则、运行时 revision/缓存分支,以及仅用于运行时的 source/rank 规范化逻辑。需要嵌入式 skill 的测试改为注册一个小型真实提供方。保留 `providerRevision` 作为进行中的发现纪元,但已完成的目录仅以 cwd 为键:每次提供方变更都同步清除缓存,await 之后的 revision 比较已能阻止插入陈旧结果。从 skill 契约和本地提供方副本中移除 `whenToUse`、`SkillCandidate.path` 和 `SkillDefinition.path`,同时保留提供方的 locator/root 路径;保留 `metadata`、`disableModelInvocation`、`source`、`provider`、`locator` 和 `resourceBase`,它们要么是刻意的扩展词汇,要么是生产中被消费的字段。 +移除 `SkillService.register()`、`SkillRegistration`、运行时伪提供方及保留名称规则、运行时 revision/缓存分支,以及仅用于运行时的 source/rank 规范化逻辑。需要嵌入式 skill 的测试改为注册一个小型真实提供方。保留 `providerRevision` 作为进行中的发现 epoch,但已完成的目录缓存仅以 cwd 为键:每次提供方变更同步清除缓存,await 之后的 revision 比较已能阻止插入陈旧结果。从 skill 契约和 local-provider 副本中移除 `whenToUse`、`SkillCandidate.path` 与 `SkillDefinition.path`,同时保留提供方的 locator/root 路径;保留 `metadata`、`disableModelInvocation`、`source`、`provider`、`locator` 和 `resourceBase`,因为它们要么是有意开放的扩展词汇,要么是生产消费的字段。 -同步修订 skill 系统 RFC、README、JSDoc、目录文件和测试。agent 作用域的系统提示词段落、工具提供方和变量明确不在本提案范围内:[agent 作用域贡献者契约](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)有意允许在 `setup(agentCtx)` 期间通过 agent 拥有的上下文注册这三者,因此仓库内没有固定的作用域注册并不能证明无人消费。 +同步修订 skill 系统 RFC、README、JSDoc、目录文件与测试。agent(智能体)作用域的系统提示词段、工具提供方和变量明确不在本提案范围内:[agent 作用域贡献者契约](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)有意允许在 `setup(agentCtx)` 期间通过 agent 拥有的上下文注册这三者,因此仓库内没有固定的作用域注册并不能证明它们未被使用。 ## 曾考虑的替代方案 -**为嵌入方保留运行时 skill 注册。** 这是已实现的 skill RFC 中一个刻意设计的同步直接定义便利接口。一个小型提供方包装层可以在 effect 拥有的生命周期下暴露相同的嵌入数据,但它必须实现异步 `list()`/`get()`、携带提供方身份、并接受提供方的重复语义。本提案选择保留一条统一的提供方路径,而非维护第二套排序、校验、缓存失效和查找路径。 +**保留面向嵌入方的运行时 skill 注册。** 这是已实现的 skill RFC 中有意提供的同步直接定义便利接口。一个小型提供方包装层可以在 effect 拥有的生命周期下暴露相同的嵌入数据,但它必须实现异步 `list()`/`get()`、携带提供方身份,并接受提供方的重复语义。本提案选择只保留一条统一的提供方路径,而非维护第二套排序、校验、缓存失效与查找路径。 ## 验收标准 -- skill 收集只有一条提供方驱动的路径;已完成缓存的键仅为 cwd;revision 纪元仅用于进行中的失效;保留的 skill 字段要么有生产读取方,要么有记录在案的刻意扩展契约。 -- agent 作用域的 prompt 段落、变量、工具提供方、工具守卫,以及原生模式和 Code Mode 下的结构化输出提交行为保持不变。 -- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建和 hygiene 全部通过。 +- skill 收集只有一条提供方驱动的路径,已完成缓存仅以 cwd 为键,revision epoch 仅用于进行中的失效检测;保留的 skill 字段要么有生产读取方,要么有记录在案的有意扩展契约。 +- agent 作用域的提示词段、变量、工具提供方、工具守卫,以及原生模式和 Code Mode 下的 structured-output 提交行为保持不变。 +- 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建与 hygiene 全部通过。 ## 风险 -这是对预发布 skill 注册表的一次编译可见的收缩。外部编程式 `list()`/`get()` 消费方将失去 `whenToUse` 路由提示和 candidate/definition 上的 `path`;已交付的模型目录从未渲染它们,资源解析保留了显式的 `resourceBase` 加上提供方自有的不透明 locator,但这些字段在可观测性上并不等价。skill 本地的 frontmatter 解析必须继续保留并校验所支持的 metadata schema,外部提供方仍可提供嵌入式、文件系统、远程或其他 skill 来源。 +这是对预发布 skill 注册表的编译可见收缩。外部编程式 `list()`/`get()` 消费方将失去 `whenToUse` 路由提示和 candidate/definition 的 `path`;已交付的模型目录从未渲染它们,资源解析保留了显式的 `resourceBase` 加上提供方自有的不透明 locator,但这些字段并非观测等价。skill 本地 frontmatter 解析必须继续保留并校验所支持的 metadata schema,外部提供方仍可提供嵌入式、文件系统、远程或其他 skill 来源。 From 6599acdee88acdafb5f25e30c953d4ef35d7e2da Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:40:46 -0700 Subject: [PATCH 008/113] docs(i18n): add the core.md pair (long-doc lane, v4 pipeline) --- docs/core-data-structures/core.i18n.yaml | 6 + docs/core-data-structures/core.md | 2 + docs/core-data-structures/core.zh.md | 403 ++++++++++++++++++++++ scripts/translation-pairing.manifest.json | 1 + 4 files changed, 412 insertions(+) create mode 100644 docs/core-data-structures/core.i18n.yaml create mode 100644 docs/core-data-structures/core.zh.md diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml new file mode 100644 index 0000000000..8017a93ecd --- /dev/null +++ b/docs/core-data-structures/core.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +core.md: 7ace373ae549b86f21151610719940100d6348d0 +core.zh.md: d6467c12e1d64624357fcb8b37688b484bc23b0c diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 8926a8a998..7ace373ae5 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -1,5 +1,7 @@ # Core Data Structures +English | [中文](core.zh.md) + This folder catalogs the **data structures** of the DeepSeek Harness — what each core type represents, its literal shape, and where the full detail lives. It complements [architecture.md](../architecture.md), which describes *behavior* (the service map, the session/turn/step lifecycle, the event taxonomy); this page describes the *vocabulary* that behavior moves around. ## What counts as "core" diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md new file mode 100644 index 0000000000..d6467c12e1 --- /dev/null +++ b/docs/core-data-structures/core.zh.md @@ -0,0 +1,403 @@ +# 核心数据结构 + +[English](core.md) | 中文 + +本目录编目 DeepSeek Harness 的**数据结构**:每个核心类型代表什么、它的字面形状,以及完整细节在哪里。它与 [architecture.md](../architecture.md) 互补——后者描述*行为*(服务映射、会话/轮次/步骤生命周期、事件分类体系);本页描述行为所操作的*词汇*。 + +## 什么算"核心" + +harness 是一个微内核:一个极小的核心加上众多插件。大多数类型属于某一个插件或某一项能力。但有少数类型构成**主干**——agent loop(智能体循环)及其事件在*每一个*轮次中使用的语言,无论加载了哪些可选插件。这些就是"核心"。 + +精确地说,一个数据结构是**核心**的,当且仅当满足以下条件之一: + +1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** +2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 + +其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节。*因此 `ToolDefinition` 是核心,但为它提供类型推导的 `SchemaSpec`/`InferArgs` DSL、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 + +| 子页面 | 负责内容 | +|---|---| +| [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | +| [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | +| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | +| [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | +| [session-query.md](session-query.md) | 逻辑会话/事件记录与有界精确事件读取 | +| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、prompt 段落与协作式组装 | +| [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | +| [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、provider API、错误分类体系 | +| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | +| [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashTask` | +| [sandbox.md](sandbox.md) | 进程隔离 seam:文件效果模式、`SandboxPolicy`、`ConfinedArgv`、强制执行与 fail-closed 错误 | +| [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | +| [filesystem.md](filesystem.md) | 文件系统 seam:`FsTarget`、读/写/编辑结果、观测到的文件状态、`FsErrorCode` | +| [skills.md](skills.md) | skill 服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | +| [compaction.md](compaction.md) | 压缩(compaction)seam:`compact/*` 会话事件、`CompactionResult`、`CompactService` 接口 | +| [subagent.md](subagent.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | +| [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、provider 可用性、`WebError` | +| [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | + +> 本页的类型定义**逐字**粘贴自源码,并由 `pnpm run verify-type-equiv` 进行漂移检查(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。为可读性省略了行内 JSDoc;完整契约请跟随源码链接查看。 + +FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog. + +## `…Map → derived-union` 模式 + +harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 + +```ts ignore-check +// The pattern, schematically: +interface ThingMap { + 'a': { kind: 'a'; /* … */ } + 'b': { kind: 'b'; /* … */ } +} +type ThingKind = keyof ThingMap // 'a' | 'b' +type Thing = ThingMap[keyof ThingMap] // the discriminated union + +// A plugin extends it without touching the source package: +declare module '@deepseek-ai/dsh-llm' { + interface ThingMap { + 'c': { kind: 'c'; /* … */ } + } +} +``` + +六个规范 map 使用此模式;插件作者扩展它们: + +| Map | 包(package) | 派生 | 目录 | +|---|---|---|---| +| `ContentBlockMap` | dsh-llm | `ContentBlock` | [下文](#content-blocks-and-messages) | +| `MessageSourceMap` | dsh-llm | `MessageSource` | [下文](#content-blocks-and-messages) | +| `FinishReasonMap` | dsh-llm | `FinishReason` | [下文](#the-model-request-and-result) | +| `TurnTriggerMap` | dsh-session | `TurnTrigger` | [session.md](session.md) | +| `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | +| `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | + +消费方最常 `switch` 的两个大型判别联合类型是:**`StreamChunk`**(流式协议)和 **`SessionEvent`**(日志条目)。按仓库约定,对标签做 `switch`——不要链式 `if`——这样每个分支都能窄化类型,拼错的标签会编译失败。 + +## 品牌化 ID + +跨包边界的 ID 是**品牌化**的——结构上是字符串,但在类型层面不可互换(`AgentId` 不能传给期望 `CallId` 的地方)。构造通过每个类型专属的工厂函数;比较、日志和 JSON 行为与普通字符串一致。 + +`Branded<B>` 原语位于自己的纯类型包 [dsh-brand](../../packages/util/brand)(无运行时代码,不依赖 harness 包),因此任何包都可以为自己拥有的 ID 品牌化,而无需依赖不相关的能力包(例如 dsh-bash 仅通过 dsh-brand 品牌化 `BashTaskId`/`OwnerToken`,从不引入 dsh-llm)。 + +Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) + +```ts type-equiv +type Branded<B extends string> = string & { readonly [BRAND]: B } +``` + +三个核心 ID:`CallId`(关联工具调用与其结果;dsh-llm)、`SessionId`(dsh-session)、`AgentId`(dsh-agent)。每个都是 `Branded<'CallId'>` 等加上同名工厂函数。能力 seam 也品牌化自己的 ID——见 [bash.md](bash.md) 中的 `BashTaskId`/`OwnerToken`。 + +## 内容块与消息 + +一段对话由 `Message` 组成;一条消息是一个类型化**内容块**的数组。块的联合类型从 `ContentBlockMap` 派生。 + +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +```ts type-equiv +interface ContentBlockMap { + 'text': TextBlock + 'reasoning': ReasoningBlock + 'tool-call': ToolCallBlock + 'tool-result': ToolResultBlock +} +``` + +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`)、`ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持。 + +`Message` 是角色加块: + +```ts type-equiv +interface Message { + role: 'system' | 'user' | 'assistant' + content: ContentBlock[] +} +``` + +消息来源本身也是一个可合并扩展的和类型: + +```ts type-equiv +interface MessageSourceMap { + user: { kind: 'user' } + plugin: { kind: 'plugin'; plugin: string } +} +``` + +## 流式输出 + +适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。 + +完整联合类型、适配器契约(usage-before-finish、原始 JSON 工具参数、两条认可的错误路径)和 `BlockAssembler` 在 **[llm-streaming.md](llm-streaming.md)** 中。 + +## 模型请求 + +一次模型调用是一个完全组装好的 `GenerateOptions`。适配器以原始 `StreamChunk` 流作答;消费方用 `BlockAssembler` 组装它(见 [llm-streaming.md](llm-streaming.md))。 + +Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +```ts type-equiv +interface GenerateOptions { + model: string + /** + * Ordered conversation messages, exactly as the provider sees them (after + * the `system` slot). A loop-built request assembles them as + * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a + * hand-built one-shot passes any list. + */ + messages: Message[] + /** System prompt text (adapters map to the provider's system slot). */ + system?: string + /** Tool schemas (adapters map to the provider's `tools` field). */ + tools?: ToolSchema[] + temperature?: number + maxTokens?: number + /** + * Stop sequences: generation halts as soon as the model produces any one of + * these strings (adapters map to the provider's stop field, e.g. OpenAI + * `stop`). The stop string itself is not included in the output. + */ + stop?: string[] + signal?: AbortSignal + /** + * Session identity stamped by the loop for listener routing. Adapters ignore + * it; replay uses it to keep concurrent parent and child cursors independent. + */ + sessionId?: Branded<'SessionId'> +} +``` + +模型停止生成的原因是一个可合并扩展的结束原因: + +```ts type-equiv +interface FinishReasonMap { + 'stop': { kind: 'stop' } + 'tool-calls': { kind: 'tool-calls' } + 'max-tokens': { kind: 'max-tokens' } + 'aborted': { kind: 'aborted' } + 'error': { kind: 'error'; message: string; code?: string } +} +``` + +`FinishReason = FinishReasonMap[keyof FinishReasonMap]`。`TokenUsage`(逐调用计量,含不相交的缓存字段)详见 [llm-streaming.md](llm-streaming.md)。 + +`GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环每一步组装请求的一部分: + +```ts type-equiv +interface ToolSchema { + name: string + description: string + /** JSON Schema object for the arguments. */ + parameters: Record<string, unknown> +} +``` + +面向模型的 `ToolSchema` 是协议格式;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 + +### 请求信封:`LlmCallConfig` 与记录的 header + +循环从已记录的状态构建每个请求。`EpochHeader` 记录调用配置、渲染后的 prompt、权威的返回工具顺序(由 `toolOrder` 配置,未设置时按字典序)以及会话前缀,通过 `request/header` 快照和 delta 实现。结合派生历史,这使得请求可从会话日志重建。见 [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta) 和[可重建请求 RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)。 + +`agent/request` 接收一个冻结的 call-config 种子,可以返回替换值。`agent/session-prefix` 在每个循环实例中组合一次仅用于请求的前缀消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求已被深度冻结,因此突变会抛出异常。 + +在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的 prompt 组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 + +FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. + +```ts type-equiv +interface LlmCallConfig { + model: string + temperature?: number + maxTokens?: number + stop?: string[] +} +``` + +## 会话 + +`Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。事件词汇从 `SessionEventMap` 派生: + +Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) + +```ts type-equiv +type SessionEvent<T extends SessionEventType = SessionEventType> = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +十五个事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`、`request/header-delta`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因,以及轮次封闭不变式在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复和 `SessionHeader`——在 **[persistence.md](persistence.md)** 中。 + +## Agent 句柄 + +`Agent` 是每个插件(UI、钩子、编排器)面向编程的接口。具体实现是 dsh-agent-loop 中的 `ReactLoopAgent`;循环之外没有任何东西依赖该实现。 + +Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +interface Agent { + readonly id: AgentId + readonly options: AgentOptions + readonly session: Session + readonly status: AgentStatus + + /** + * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent): + * registrations through it — tools, prompt sections/variables, listeners, + * restrictions — are visible to this agent only and unwind when it is + * disposed; `agent.ctx.on('agent/…')` listeners fire only for this agent. + */ + readonly ctx: Context + + /** + * Queue a user message. Starts a turn when idle; otherwise waits for the next + * turn. Content and the resolved source are accepted as one detached, + * deeply-frozen lossless-JSON record before notification or enqueue, so + * caller or `agent/queued` listener in-place mutation cannot change later + * log/model input. Throws synchronously when either value is not losslessly + * JSON-serializable; `agent/prompt-submit` may still return an explicit + * replacement. + */ + send(content: ContentBlock[], options?: SendOptions): void + + /** + * Steer a running turn: content is injected between steps of the current + * turn. Uses the same owned-value and synchronous-validation boundary as + * {@link send}; when idle, behaves exactly like that method. + */ + steer(content: ContentBlock[], options?: SendOptions): void + + /** + * Inject in-session context (file-change notices, skill content, cron + * notifications, …): appends a `context/message` session event the next model + * request sees at its chronological position, rendered as tagged synthetic + * context rather than a user prompt. Does not run the model. + * + * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; + * an inject while idle wraps its `context/message` in a one-shot `injection` + * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for + * durability, so every event stays inside a turn and a persistence backend + * never loses a between-turn notice. The idle checkpoint is fire-and-forget + * (inject is synchronous): a failing flush is reported via `agent/error` + * (step `0`) and the logger, never thrown into the caller. + * + * Live-adapter review has validated the tagged-envelope rendering against + * current DeepSeek behavior; provider-specific mismatches belong in that + * adapter, not in the canonical session vocabulary. + */ + inject(content: ContentBlock[], options?: SendOptions): void + + /** + * Cancel ALL pending work for the agent. `cancel()`: + * + * - clears the queued FIFO (un-started prompts never run) and the steering + * FIFO (steering for the cancelled turn is dropped, not re-enqueued); + * - aborts the in-flight step if one is running (the turn ends `aborted`); + * - drops a turn that is about to start (a `cancel()` landing in the + * pre-step window — after a `send()` queued but before the loop flips to + * `running`, or after `running` is emitted but before the first step) so + * that queued prompt does not run and cannot be batched into the cancelled + * turn. + * + * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. + * `cancel()` on an idle agent with nothing queued or running is a safe no-op + * — it does NOT arm anything that would drop a later legitimate prompt. + */ + cancel(reason?: string): void + + /** + * Resolve once the agent has reached quiescence after settling out of + * `running`, or immediately if it is already idle with no queued work. A + * non-owner's quiescence-observation hook: a consumer that does NOT own the + * agent's lifecycle awaits this to proceed only after queued/running work has + * fully stopped, rather than returning while the driver is still streaming or + * about to start a queued turn — without itself tearing the agent down. (A + * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the + * loop-exit promise directly as part of stopping and unregistering. So this is + * for a non-owning observer — e.g. a test awaiting a turn to settle, or a + * monitor — that wants the settle signal but must not dispose the agent.) + * + * "Quiescence", not merely "status changed": a disposed agent emits + * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop + * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop + * to actually exit (the implementation chains the loop-exit promise), not just + * observe the status flip. A mid-step disposal that never reaches `idle` still + * unblocks the await this way. + */ + whenIdle(): Promise<void> + + // Subagent delegation is realized on top of this interface by the + // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates + // the child through `ctx.agents.create` (fork seeds the child Session with a + // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn + // starts fresh) and drives it as an ordinary Agent handle, so steer() and + // event subscription work uniformly. See docs/core-data-structures/subagent.md. +} +``` + +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`AgentId` 是品牌化的。`AgentOptions` 可合并扩展,当前包含 `model?`。Persona 属于 `dsh-system-prompt`:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 + +[事件分类体系](../architecture.md#event)拥有 `agent/*` 生命周期、检查点和 waterfall(瀑布式事件)契约。轮次和步骤边界是持久的会话事件,而非 agent 发射。 + +## 拦截决策 + +每个 `agent/*` 拦截 waterfall 返回一个小型的、seam 特定的类型化联合——统一的 Decision 惯用法(工具 seam 的 `PreToolDecision`/`PostToolDecision` 在 [tools.md](tools.md) 中遵循相同形状)。CC/Codex 钩子桥将其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些类型上;原生插件直接返回它们。它们共享一个面向模型的上下文信封 `HookContext`,通过 `inject()` 作为 `context/message` 注入,因此携带一个必需的 `source`(缺少 source 会默认为 `{kind:'user'}`,将插件上下文错误标记为用户提示词)。 + +Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +```ts type-equiv +interface HookContext { + content: ContentBlock[] + source: MessageSource +} +``` + +`agent/prompt-submit` 返回 `PromptDecision`(允许一条已出队的排队消息——可选地重写其 `content` 或附加 `additionalContext`——或阻止它;一个批次中所有 prompt 都被阻止时,会打开一个零步骤轮次并以 `rejected` 结束): + +```ts type-equiv +type PromptDecision = + | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'block'; reason: string } +``` + +`agent/turn-continuation` 返回 `ContinuationDecision`(循环的默认行为是:当步骤有工具调用或 steering(中途引导)被注入时 `continue`,否则 `stop`;`continue` 的 `reason` 被记录为同一轮次中下一步的 steering——类型化的 `/goal` 模式): + +```ts type-equiv +type ContinuationDecision = + | { action: 'stop' } + | { action: 'continue'; reason?: HookContext } +``` + +`agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。 + +```ts type-equiv +type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }> +``` + +`agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): + +```ts type-equiv +type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' +``` + +`agent/session-prefix` 在每个循环实例中组合一次 `Message[]`。深度冻结的结果被记录在请求 header 中,并前置于每次派生历史,使其成为会话稳定开场白的归属。恢复的实例会重新组合;会话中途的变更使用仅追加的上下文通道。该 waterfall 直接返回内容,因为它是贡献而非决策。 + +## `ToolDefinition` + +唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数和可选的 UI 展示器。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的契约。 + +其完整字段、`defineTool`/`SchemaSpec`/`InferArgs` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index be766dbaad..60ceaa876b 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -14,6 +14,7 @@ "docs/core-data-structures/bash.md", "docs/core-data-structures/code-runtime.md", "docs/core-data-structures/compaction.md", + "docs/core-data-structures/core.md", "docs/core-data-structures/filesystem.md", "docs/core-data-structures/llm-streaming.md", "docs/core-data-structures/persistence.md", From 515d13db7f26d9cd8317612a5c406039ce64f3ab Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:42:25 -0700 Subject: [PATCH 009/113] docs(i18n): add code-mode and web-capability-seam pairs (long-doc lane) --- .../2026-06-24-web-capability-seam.i18n.yaml | 6 + .../2026-06-24-web-capability-seam.md | 2 + .../2026-06-24-web-capability-seam.zh.md | 334 ++++++++++++++++++ scripts/translation-pairing.manifest.json | 1 + 4 files changed, 343 insertions(+) create mode 100644 docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml new file mode 100644 index 0000000000..7a19846a47 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-06-24-web-capability-seam.md: 3d0cdd89bb8366749ee0b3959244db1c57c0ccaa +2026-06-24-web-capability-seam.zh.md: 1cb1169f99b6eed22bcca650e0b3fe184f331307 diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 2909ccd0e6..3d0cdd89bb 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -1,5 +1,7 @@ # RFC: Web capability seam - stable tools over multiple providers +English | [中文](2026-06-24-web-capability-seam.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md new file mode 100644 index 0000000000..1cb1169f99 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -0,0 +1,334 @@ +# RFC:Web 能力 seam——稳定的工具覆盖多个提供方 + +Status: implemented + +[English](2026-06-24-web-capability-seam.md) | 中文 + +## 问题 + +harness 需要面向模型的 web 工具,但不能将模型契约绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 seam 并非只是镜像某一家厂商。Fetch 是另一项独立能力:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。 + +面向模型的接口必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。 + +如果把搜索和 fetch 直接放进 `dsh-tool-web`,面向模型的工具就要同时承担提供方选择、后端请求映射、传输策略、结果归一化、prompt 引导、展示和 schema 注册。让每个提供方注册自己的工具则有相反的问题:工具的可用性、名称、描述和参数将取决于恰好加载了哪些提供方包,提供方特有字段会泄漏到模型契约中。 + +还有一个提供方选择的问题。现有的 `tool-bash` 和 `tool-fs` 可以依赖 Cordis 的 `inject`,因为只有一个后端服务键。Web 有两项独立能力(`search` 和 `fetch`),每项能力可能有多个提供方。`inject: ['web']` 能证明 seam 存在,但不能证明存在可用的搜索或 fetch 提供方,也无法定义多个提供方注册时谁胜出。 + +## 决策 + +Web 访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): + +1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 +2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-local`。 +3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、prompt 段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。 + +提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、prompt 引导、JSON Schema、展示的唯一所有者。 + +搜索和 fetch 是两个独立工具,但属于同一个 web 访问 seam。`ctx.web` 为两个并行注册表统一拥有提供方选择、abort/错误词汇和部署配置。它们的请求 schema 和提供方逻辑保持独立;共享的服务是触达 web 的产品边界。 + +`dsh-tool-web` 在产品启用了相应工具且 `ctx.web` seam 存在时注册面向模型的 web 工具。后端可用性是执行时关注点,而非 schema 注册时关注点: + +- `web_search` 在产品/应用启用了 web 搜索时注册,`web_fetch` 在启用了 web fetch 时注册。 +- 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。 +- 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。 + +这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的契约。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 + +该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 RFC](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 + +## 包拓扑 + +由三个包构成的接口/实现/消费方拆分沿用 bash 和 filesystem 的模式,但*接口*包更接近 LLM(大语言模型) seam。`LlmService`(`packages/llm/llm/src/index.ts`)是一个按名称键控的提供方注册表:`registerAdapter(models, adapter)` 将适配器存入 `Map`、返回 disposer、对重复键抛出 `DUPLICATE_ADAPTER`、在解析时抛出 `NO_ADAPTER`。`ctx.web` 沿用该注册表形状,但有两种能力类别和更丰富的选择策略(配置的提供方 id,或在恰好只有一个可用提供方注册时自动选择),因此执行时抛出的 `WebError` 能解释搜索或 fetch 能力为何无法运行。 + +依赖方向与 bash 和 filesystem 一致: + +```text +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa + consumer interface implementation + <--depends on-- @deepseek-ai/dsh-web-search-perplexity + implementation + <--depends on-- @deepseek-ai/dsh-web-search-deepseek + implementation + <--depends on-- @deepseek-ai/dsh-web-fetch-local + implementation +``` + +运行时,提供方包向 `ctx.web` 注册能力;`tool-web` 向 `ctx.tools` 注册稳定的工具并通过 seam 执行: + +```mermaid +flowchart LR + exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] + perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web + toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web + toolWeb -->|ctx.tools.register| webSearch["tool: web_search"] + toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] +``` + +`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性契约和错误码。它不导入 tool、agent、session、LLM 或提供方包。 + +提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 + +`@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。 + +## `ctx.web` 契约 + +`ctx.web` 是一个提供方注册表加上一个带提供方选择的执行面。注册表部分与 `LlmService` 保持接近:每种能力类别一个 `Map<id, provider>`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状: + +```ts +interface WebSearchProvider { + readonly id: string + available(): boolean + search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> +} + +interface WebFetchProvider { + readonly id: string + available(): boolean + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> +} + +interface WebService { + registerSearchProvider(provider: WebSearchProvider): () => void + registerFetchProvider(provider: WebFetchProvider): () => void + + search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> +} +``` + +可选的 signal 是执行控制,而非业务输入:`tool-web` 直接传递 `exec.signal`,使轮次取消、工具超时和 agent dispose(资源释放)能到达提供方的网络请求、流读取器和高开销解码。seam 不传递 `ToolExecution`——否则 `dsh-web` 就要依赖 `dsh-tools`。 + +提供方 id 是稳定字符串,在各自的能力类别内唯一。注册重复的搜索提供方 id 或重复的 fetch 提供方 id 会失败,而非静默替换旧提供方。提供方注册返回 disposer,沿用现有的 `ctx.tools.register()`/`ctx.systemPrompt.section()` 模式:变更包裹在 `ctx.effect()` 中,注册随贡献它的 fiber 一起拆除。 + +## 提供方可用性与选择 + +提供方可用性与能力选择是两个独立概念,但都保持最小化。提供方仅报告该具体实现是否可用,通过廉价的本地检查(如凭证是否存在、端点配置是否可解析)。提供方的 `available()` 禁止发起网络调用。 + +`LlmService` 完全没有状态类型:可用性通过注册表成员资格加解析时抛出来表达。`ctx.web` 遵循同样的纪律。seam 不暴露聚合的能力状态查询——`search()`/`fetch()` 在每次调用时根据配置的提供方 id、已注册的提供方和每个提供方廉价的本地 `available()` 布尔值派生选择结果,选择失败就是执行时抛出的结构化 `WebError`。需要知道某项能力能否运行的调用方通过执行并路由该错误来获知;没有任何东西作为可变服务状态存储。 + +该布尔值是选择的输入,而非健康系统。`tool-web` 从不直接调用提供方的 `available()`——它进入 seam 的唯一路径是 `search()`/`fetch()`——因此选择策略只有一个所有者。 + +选择不得依赖注册顺序。Cordis 加载顺序、配置排列和 HMR 时序不是产品语义。 + +| 情况 | 执行行为 | +|---|---| +| 配置的提供方 id 已注册且 `available() === true` | 运行该提供方 | +| 配置的提供方 id 未注册 | 以 `WEB_PROVIDER_CONFIGURED_MISSING` 失败 | +| 配置的提供方 id 已注册但不可用 | 以 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 失败 | +| 未配置提供方 id,且该类别恰好有一个已注册且可用的提供方 | 运行该唯一提供方 | +| 未配置提供方 id,且该类别无已注册提供方 | 以 `WEB_PROVIDER_UNAVAILABLE` 失败 | +| 未配置提供方 id,且该类别有多个可用提供方已注册 | 以 `WEB_PROVIDER_AMBIGUOUS` 失败,而非按注册顺序选择 | +| 未配置提供方 id,且有提供方存在但均不可用 | 以 `WEB_PROVIDER_UNAVAILABLE` 失败 | + +「唯一提供方自动选择」规则面向测试、演示和简单部署。产品配置设置显式提供方 id: + +```yaml +- id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: exa + fetchProvider: local-http + +- id: web-search-exa + name: '@deepseek-ai/dsh-web-search-exa' + +- id: web-search-perplexity + name: '@deepseek-ai/dsh-web-search-perplexity' + +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' +``` + +运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。 + +`ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。 + +## 搜索请求与结果 schema + +面向模型的 `web_search` 工具很小。唯一的面向模型参数是: + +- `query`:必填字符串。 + +`max_results` 不暴露给模型。它是 `dsh-tool-web` 层的决策:工具设定结果上限——`searchMaxResults` 插件配置,默认 `8`(与 OpenCode 的 Exa 默认值对齐),类似 `dsh-tool-fs` 的 `readLimit`——并作为 `WebSearchRequest` 上的 `maxResults` 传给 seam。将其排除在模型 schema 之外意味着模型只需提问,产品控制返回多少上下文;该字段日后可以提升为面向模型的参数而不破坏 seam。 + +`maxResults` 沿 tool → seam → provider 流动,上限在返回路径上强制执行: + +- `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。 +- `ctx.web` 将请求原样传递给选定的提供方。 +- 当提供方的 API 支持结果数量控制时(Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。 +- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制(Perplexity)或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。 + +seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。 + +```ts +interface WebSearchRequest { + readonly query: string + /** Upper bound on returned sources; the seam truncates to it. Omitted = no bound. `dsh-tool-web` always sets it. */ + readonly maxResults?: number +} + +interface WebSearchResult { + readonly content?: string + readonly sources: readonly WebSearchSource[] + readonly truncated: boolean +} + +interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + readonly publishedAt?: string +} +``` + +`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用面。source 必有 URL;title、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它,Perplexity 在搜索结果上返回 `date`,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。 + +Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet,被丢弃)、`publishedAt` ← `publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`(常为空)、`publishedAt` ← `date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。 + +完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。 + +## Fetch 请求与结果 schema + +`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `local-http`。它从具体 URL 获取字节,应用下述基本传输卫生措施(仅 http/https、拒绝 URL 中的凭证、字节/时间上限、跨源重定向阻断),解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。(完整的 SSRF/私有网络阻断推迟——见[推迟工作](#deferred-work)。) + +seam 请求比 OpenCode 的面向模型工具更小: + +- `url`:必填 HTTP(S) URL。 + +seam 请求刻意不包含逐调用超时、`format`、`prompt` 或提供方特有的提取控制。取消通过直接的可选执行信号实现,fetch 提供方拥有一个部署配置的超时兜底。`format` 是对已获取资源的展示决策;`prompt` 是更高层的 LLM 摘要指令;Firecrawl、Exa、Tavily 或 Parallel 等提取 API 可能不暴露具体的 HTTP 响应。如果产品日后需要提供方支撑的页面提取,那是一个独立的 `web_extract` 能力或对本 seam 的刻意扩展——提取语义绝不通过将每个 HTTP 字段设为可选来偷渡进 `web_fetch`。 + +HTTP 状态码是已获取资源状态的一部分,不自动构成工具失败。成功的网络获取一个 `404` 或 `500` 响应会返回带有状态码和有界解码正文(当内容类型受支持时)的 `WebFetchResult`。`WebError` 用于无法安全获取或表示资源的失败:无效或被阻断的 URL、重定向策略违规、超时、abort、响应过大、不支持的内容类型、提供方失败或网络失败。 + +```ts +interface WebFetchRequest { + readonly url: string +} + +interface WebFetchResult { + readonly url: string + readonly statusCode: number + readonly body: WebFetchBody + readonly truncated: boolean +} + +type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } +``` + +`WebFetchResult.url` 是允许的重定向之后的最终 URL。请求 URL 已在 `WebFetchRequest` 中,因此没有单独的 `requestedUrl`/`finalUrl` 对。 + +`WebFetchBody` 是封闭的可辨识联合类型,因为正文类别需要 seam、提供方和工具三方协调变更,而非独立的插件扩展。穷举 switch 使新类别在每个渲染器处编译失败,直到被处理。独立的对象分支为类别特有字段留出空间。 + +提供方负责安全的资源获取:URL 校验、HTTP 传输、重定向策略、超时、abort 传播、字节上限、字符集解码、内容类型分类和二进制拒绝。`dsh-tool-web` 负责展示:HTML 转 Markdown、HTML 转纯文本、面向模型的截断格式化,以及未来的摘要。 + +fetch 提供方的资源控制: + +- 仅接受 `http:` 和 `https:` URL;拒绝 URL 中的凭证。 +- 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。 +- Abort 信号传播到网络获取和高开销解码。 +- 仅自动跟随同源重定向;跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用,从而触发新的提供方/权限决策。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。) +- 请求携带显式的产品 User-Agent,而非静默伪装浏览器。 + +SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他非公开目的地,通过先 DNS 解析再验证 IP 来防御 rebinding,并在重定向的每一跳重新验证)**推迟**——见[推迟工作](#deferred-work)。在其落地之前,`web_fetch` 是一个 SSRF 原语,不得在能触达敏感内部网络目标的部署中启用。 + +## 工具消费方行为 + +`dsh-tool-web` 拥有两个 `ToolDefinition`:`web_search` 和 `web_fetch`。它拥有面向模型的 JSON Schema、snake_case 参数名、prompt 段落、结果渲染为 `ContentBlock[]`、`presentCall` 和 `presentResult`。 + +`dsh-tool-web` 禁止枚举提供方或直接调用提供方的 `available()`。它进入 seam 的唯一路径是 `ctx.web.search()`/`ctx.web.fetch()`。这将提供方选择保持在单一层;否则工具包可能判定某个提供方可用,而执行时解析出不同的状态。 + +工具注册是最小化的稳定同步:插件启动时,`dsh-tool-web` 的 `Config`(`search?: boolean`、`fetch?: boolean`,均默认 `true`)启用或禁用每个 web 工具;已启用的工具通过基于 effect 的注册表以 fiber 作用域的 disposer 注册;任何工具都不会仅因其选定的提供方缺失、不可用或存在歧义而被 dispose;dispose `tool-web` fiber 时自动拆除其注册。 + +提供方可用性变化影响执行结果和诊断信息,而非面向模型的 schema 是否存在。如果产品完全不需要 web 工具,在配置中禁用 `dsh-tool-web` 或单个 web 工具即可;如果需要 web 工具但后端配置有误,模型在执行时看到结构化的工具错误。 + +prompt 引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——prompt 和工具结果告诉模型用 Markdown 链接引用相关 URL。 + +面向模型的输出以文本为先,因为工具结果是 `ContentBlock[]`,但 seam 的产出保持结构化,以便 UI 展示和未来的适配器无需解析渲染后的文本。 + +## 错误 + +`dsh-web` 定义 `WebError extends HarnessError`,带有稳定的错误码,仅覆盖调用方可能合理分支的状态: + +- `WEB_PROVIDER_UNAVAILABLE` +- `WEB_PROVIDER_CONFIGURED_MISSING` +- `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` +- `WEB_PROVIDER_AMBIGUOUS` +- `WEB_DUPLICATE_PROVIDER` +- `WEB_INVALID_URL` +- `WEB_BLOCKED_URL` +- `WEB_REDIRECT_BLOCKED` +- `WEB_FETCH_TOO_LARGE` +- `WEB_FETCH_TIMEOUT` +- `WEB_ABORTED` +- `WEB_UNSUPPORTED_CONTENT_TYPE` +- `WEB_PROVIDER_ERROR` + +`WEB_DUPLICATE_PROVIDER` 在 `registerSearchProvider`/`registerFetchProvider` 发现该能力类别中已有相同 id 时同步抛出(类似 `LlmService` 的 `DUPLICATE_ADAPTER`);它是注册时的编程错误而非执行结果,但共享 `WebError` 码空间,使调用方看到统一的分类体系。`WEB_PROVIDER_ERROR` 是提供方自身失败通过 seam 浮出的兜底码,包括 `web-fetch-local` 中的网络/传输失败(DNS、连接拒绝、TLS);刻意不设单独的 `WEB_NETWORK` 码——提供方设置描述性消息,使模型和日志能区分网络失败与提供方 API 失败。 + +工具执行让这些错误流经 `ToolRegistry.execute()`,后者已将 `HarnessError` 转换为带结构化元数据的错误工具结果。模型得到可读的错误消息;钩子、测试和 UI 代码可以根据稳定的错误码路由。 + +## 测试 + +每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 + +## 曾考虑的替代方案 + +### 让每个提供方注册自己的面向模型工具 + +这与最灵活的提供方插件系统一致:每个提供方可以暴露其完整的原生 schema。在 harness 中被否决,因为它将面向模型的名称、描述、prompt 引导和结果格式化的所有权交给了提供方包。多个搜索提供方会产生重复的工具名或提供方特有的工具名,模型将学到后端细节而非稳定的产品能力。 + +### 将提供方调度直接放在 `dsh-tool-web` 中 + +这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。 + +### 将搜索和 fetch 拆为两个 seam(`dsh-search`、`dsh-fetch`) + +很有吸引力,因为两半不共享请求 schema 和业务逻辑,各自能干净地映射到 bash/fs 的三包模板上,且 `WebService` 上的 `Search`/`Fetch` 方法对重复也会消失。否决,因为共享的机制——提供方 id 注册表、不依赖注册顺序的选择策略、abort 传播、`WebError` 分类体系,以及面向产品的「这个 harness 如何触达 web」配置面——是真实存在的,否则会在两个几乎相同的 seam 之间重复。一个 `ctx.web` 中间层给产品一个统一的注入和配置对象,给提供方选择一个唯一的所有者。代价是并行的 `searchX`/`fetchX` 方法对,这是有意接受的。 + +### 选择第一个注册的提供方 + +否决。注册顺序不是产品策略。它可能随配置顺序、插件加载、HMR 或重构而变化。提供方选择必须是显式的,或仅在恰好只有一个可用提供方时自动选择。 + +### 将 Firecrawl/Exa/Tavily/Parallel 提取视为 fetch + +在第一版中否决。这些提供方通常返回提取或摘要后的内容,而非具体的 HTTP 响应。如果产品需要提取,日后设计 `web_extract` 或刻意扩展 fetch seam。 + +### 镜像 Claude Code 的 `url + prompt` WebFetch 形状 + +在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。 + +## 后果 + +**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 + +**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。 + +**稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当醒目地浮出结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,使用户不会在模型调用工具后才发现配置问题。 + +**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。 + +**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 能触达敏感网络目标或通过 URL 外泄数据。仅交付基本传输卫生措施(仅 http/https、拒绝凭证、字节/时间上限、跨源重定向阻断);SSRF/私有网络阻断推迟(见[推迟工作](#deferred-work)),因此在其落地之前,`web_fetch` 不得在能触达内部目标的环境中启用。 + +**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。 + +## 推迟工作 + +- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的 prompt),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 +- `pdf` `WebFetchBody` 类别:`local-http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 +- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 +- 推迟的权限系统落地后的权限策略集成。 +- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 + +## 开放问题 + +- 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出? +- 推迟的权限系统落地后,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 86368ef25d..c836ec18de 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -58,6 +58,7 @@ "docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md", "docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md", "docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md", + "docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md", "docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md", "docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md", "docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md", From 0b1857904a3bad8e8e90ae832c755b936fe0cadf Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:20:18 -0700 Subject: [PATCH 010/113] =?UTF-8?q?docs(i18n):=20add=20agent-scope=20and?= =?UTF-8?q?=20sandbox=20pairs=20=E2=80=94=20the=20batch=20is=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最后两篇超长 RFC 经流式通道产出(代理会掐非流式长请求),v4 流水 线同一契约;至此 185 篇批次全部配对,pairing 194 对全绿。 --- ...07-12-agent-scope-runtime-design.i18n.yaml | 6 + .../2026-07-12-agent-scope-runtime-design.md | 2 + ...026-07-12-agent-scope-runtime-design.zh.md | 392 ++++++++++++++++++ .../feature/2026-07-06-sandbox.i18n.yaml | 6 + .../implemented/feature/2026-07-06-sandbox.md | 2 + .../feature/2026-07-06-sandbox.zh.md | 212 ++++++++++ scripts/translation-pairing.manifest.json | 2 + 7 files changed, 622 insertions(+) create mode 100644 docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md create mode 100644 docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml new file mode 100644 index 0000000000..5f2ed7f3bc --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-12-agent-scope-runtime-design.md: 5fe44f2b0a79d91ce0e32a687ed183a5ffaef284 +2026-07-12-agent-scope-runtime-design.zh.md: 6e4f09e6780858c90b0cfbcf8709eca4c79ee414 diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 2c3126793f..5fe44f2b0a 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -1,5 +1,7 @@ # RFC: Agent-scope runtime design and correctness +English | [中文](2026-07-12-agent-scope-runtime-design.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md new file mode 100644 index 0000000000..6e4f09e678 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -0,0 +1,392 @@ +# RFC:Agent 作用域运行时设计与正确性 + +[English](2026-07-12-agent-scope-runtime-design.md) | 中文 + +Status: implemented + +## 问题 + +[agent 作用域契约](2026-07-08-agent-scope-contexts.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份契约。 + +主要的设计风险是为每个竞态条件引入第二套机制。独立的预留、就绪哨兵、取消中继、快照层和保护注册表可能镜像同一个事实,直到没有读者能分辨哪个才是权威的。这些机制还会诱使运行时把可信的类型化调用当作敌对的序列化边界来处理。 + +实现需要足够的状态来维护真实的所有权和结算边界,但不能更多。正确性审查者必须能够从接受、发布到拆除,沿着一条事实链跟踪下去,而无需在并行的表示之间做调和。 + +## 决策 + +运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体;每个活跃的注册表对象有一条入口记录;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和静默态。 + +该设计可概括为七项选择: + +| 问题 | 权威机制 | +|---|---| +| 选择全局加某个 agent 的注册 | 不透明作用域键与路由载体 | +| 拥有一个活跃的 agent 或会话 | 由其 disposer 捕获的单条注册表入口 | +| 协调创建/恢复 | 单个 `AgentCreationTransaction` | +| 保护持久化、队列、模型或协议格式数据 | 在该边界处一次性物化 | +| 在同一进程内传递类型化值 | Readonly 借用契约 | +| 组合模型可见的 prompt 与工具表面 | 单个共享工具视图加权威的 assembly-waterfall 结果 | +| 协调 subagent、worker 和进程关闭 | 单个取消信号加该边界独立的终止态/静默态事实 | + +本 RFC 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与 prompt、subagent 与工作流,最后是可执行检查。 + +[7 月 8 日 RFC](2026-07-08-agent-scope-contexts.md) 仍然是贡献者契约。独立的 [subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 + +## Cordis 模型:context、fiber、effect、receiver 与 waterfall + +理解实现需要五个 Cordis 概念。Context 选择服务和注册所有权;fiber 是一个活跃的插件或子生命周期;effect 将清理逻辑附加到 fiber;event receiver 选择监听器;waterfall(瀑布式事件)让监听器按顺序变换或否决一个操作。 + +### Context 是贯穿单个服务图的所有权路径 + +所有 agent 共享一个 Cordis 服务图。派生的 context 不会克隆 `ToolRegistry`、`SystemPrompt`、持久化或模型适配器;它改变的是:通过该 context 进行的注册如何被标记,以及哪些 effect 拥有其清理逻辑。 + +`agent.ctx` 就是这样一个派生 context。服务调用仍然到达共享实例,而注册操作可以检查其调用 context 并将贡献存储在最近的作用域键下。普通的插件 context 不携带作用域键,因此注册到全局。 + +### Fiber 与 effect 使清理成为结构性的 + +Cordis fiber 是插件或子 context 被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该 context 注册的一切,无需单独的清单。 + +vendor 中的 Cordis fiber 实现在任意 setup 或 `internal/plugin` 观察者运行之前就建立了所有权。可重入的卸载可以看到已启动的子 fiber 或 effect,拒绝卸载开始后添加的 effect,并通过一个公开的一次性 disposer 加入已启动的清理。拆除观察者被逐个隔离,因此一个回调无法阻止结构性清理。 + +这些是框架生命周期保证,而非 agent 特有的策略。Agent 创建依赖它们,因为 setup 可以激活任意插件并同步重入所有者的 dispose。 + +### Receiver 路由监听器;waterfall 组合决策 + +Cordis 使用 dispatch receiver(`this`)过滤监听器,而 harness 的监听器需要一个显式的 agent、execution、request 或其他主体。`Scoped<T>` 标记作用域事件声明所期望的 receiver,但运行时载体刻意不暴露主体 API。 + +因此,产品辅助函数构造载体并单独传递领域主体。这防止监听器路由变成另一套对象模型,并使事件签名在不了解载体内部的情况下也可理解。 + +Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()`:调用它则委托给剩余监听器和基础操作,不调用则否决或替换下游结果。Waterfall 驱动 prompt 组装和工具策略;普通 emit 事件同步通知,parallel 事件等待所有监听器但没有否决结果。 + +## 作用域路由:一个不透明键选择一层 + +scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个组合的服务过滤器和作用域谓词,而包私有地记录不透明键,并单独暴露作用域 fiber 的静默 disposer。 + +### 作用域标识使用对象标识 + +`ScopeKey` 是一个按标识比较的不透明对象。Harness 使用活跃的 `Agent` 作为自身的键,但该原语与领域无关,支持其他作用域所有者。 + +`createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建 event receiver,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。 + +Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。 + +### 注册表读取叠加一个精确映射 + +作用域感知的注册表将全局贡献与按标识键索引的局部贡献分开存储。读取解析全局层和至多一个局部层;它从不遍历父级链。 + +每个服务保留其领域规则。命名 prompt 值和工具使用局部遮蔽,工具限制在添加局部工具之前过滤全局,事件选择监听器受众而非注册数据。Scope 提供标识和所有权,而非通用的合并算法。 + +### 融合 dispatch 辅助函数防止主体漂移 + +`agentEvents(context, agent)` 构造 agent 的载体并注入同一个 agent 作为事件主体。Session、tool、approval、prompt 和 subagent 服务同样从它们已拥有的对象派生路由,而非接受一个无关的键。 + +类型标记拒绝普通的裸 receiver 误用,开发环境不变式覆盖直接 JavaScript 或强制转换的 dispatch。主体保持显式,因为路由正确性和有用的事件数据是不同的关注点。 + +## Agent 创建:一个事务拥有完整操作 + +创建和恢复是一个具有多个阶段的异步生命周期,而非多个生命周期。`AgentCreationTransaction` 拥有调用方和工厂的活跃性、可选取消、私有资源、发布、回滚,以及每个所有者观察到的记忆化拆除。 + +### 注册表入口是唯一的活跃标识记录 + +AgentRegistry 和 SessionStore 各为每个活跃对象保留一条入口。入口持有稳定 ID、对象、作用域载体,以及属于该对象的少量发布或追加状态。 + +detach 闭包捕获其确切入口。它仅在映射仍指向该入口时才删除,因此旧的 disposer 无法删除一个复用相同 ID 的后续对象。注册表不会重读可变的调用方对象来决定标识。 + +没有预留 API。调用方提供的 ID 在最终入口时被接纳。并发的同 ID 操作可能都完成私有 setup;恰好一个最终 `enter()` 成功,每个失败者回滚其私有资源。前一个 disposer 达到静默后,顺序复用即为有效。 + +### 事务在等待之前就拥有准备工作 + +事务在持久化加载或 setup 可能挂起之前,就被安装到调用方的 Cordis context 和具体的 AgentLoop 工厂下。它还在公开操作结算之前观察可选的创建/恢复信号。 + +创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。 + +工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。这保留了依赖来源和调用方所有权,而不堆叠 trace 代理。 + +### Setup 是私有世界内的可信组合 + +Setup 接收完整的子 context,可以等待插件激活。它可以注册工具、prompt 段、限制、监听器和其他 effect,但公开契约不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 + +事务将异步加载和 setup 与停用进行竞争,而非无限等待外部代码拥有的 promise。如果取消或所有者卸载获胜,即使外部 promise 永不结算,公开创建也会在事务拥有的清理之后拒绝。 + +### 发布有一条有序的提交路径 + +发布按观察者所需的顺序接纳和宣告资源: + +1. 入口 session。 +2. 入口 agent。 +3. 宣告 `session/created`。 +4. 宣告 `agent/created`。 +5. 启用公开驱动。 +6. 发射 `agent/session-start`。 +7. 启动 driver。 + +Agent 在两个注册表和创建通知都达成一致之前绝不驱动。同步监听器可以否决或 dispose 一个所有者;事务记录发布进行中,并等待该回调栈展开后再继续拆除。每个已开始的创建宣告在回滚期间都有匹配的销毁宣告。 + +以下序列图隔离了非显而易见的竞态:同步创建监听器可以在发布调用栈仍拥有两个注册表入口时请求 dispose。拆除必须立即停用,但要等待该栈展开后才停止和分离任何东西。 + +```mermaid +sequenceDiagram + participant Tx as AgentCreationTransaction + participant Registries + participant Listener as Synchronous listener + participant Driver + + Tx->>Tx: mark publication in progress + Tx->>Registries: announce agent/created + Registries->>Listener: invoke inside the same call stack + Listener->>Tx: dispose reentrantly + Tx->>Tx: deactivate, teardown waits for publication + Tx-->>Listener: disposal request accepted + Listener-->>Registries: return + Registries-->>Tx: announcement unwound + Tx->>Tx: resolve publication settlement + Tx->>Driver: stop and drain + Tx->>Registries: detach agent, then session + Tx->>Tx: dispose scope and resolve teardown +``` + +### 拆除在撤销注册之前保留工作 + +每个拆除请求加入一条记忆化路径。顺序为: + +1. 停用创建或驱动,让同步发布完成。 +2. 停止并排空 driver,包括空闲注入刷新。 +3. 分离 agent。 +4. 分离 session。 +5. Dispose agent 作用域。 +6. 退役事务所有权追踪。 + +此顺序让最终的 agent 和 session 事件能使用匹配的作用域监听器,并使持久化观察者在最终刷新完成前保持附加。作用域 dispose 放在最后,因为注册撤销是外部可见的生命期边界。 + +## 会话追加:物化、验证、提交、通知 + +会话事件跨越持久化边界,因此追加操作拥有其数据。算法的其余部分使用一条附加的入口和一个提交点。 + +### 持久化数据一次性物化 + +Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造函数或追加路径在存储前物化并验证它们,并暴露冻结的快照,因此后续调用方的修改无法改变持久化、回放或模型重建。 + +这是一个真实的所有权边界:值离开调用方,可能被持久化,且必须在之后重建相同的请求。这比类型化的同进程回调或注册表定义有意更严格。 + +### 提交前监听器可以否决;提交后观察者不能 + +追加遵循一个序列: + +1. 物化持久化事件和表面意图。 +2. 声明 SessionEntry 并拒绝该入口上的重入追加。 +3. 解析作用域回调并运行内部不变式验证。 +4. 恰好推送一次;这是提交点。 +5. 逐个通知每个观察者,隔离同步和异步失败。 +6. 释放追加状态并兑现发布期间请求的 detach。 + +没有观察者错误能让已提交的事件看起来未提交,一个坏的监听器也无法饿死后续监听器。Session 不变式在提交前暂存其转换,仅当同一事件到达被隔离的提交后观察者时才应用。 + +`flush()` 启动每个持久化监听器并等待所有结果后再报告失败。这种有意的 all-settled 行为防止同步失败饿死另一个后端或最终刷新。 + +## 信任边界:仅在所有权真正变更时复制 + +运行时区分类型化的进程内契约与序列化及持久化边界。这是值和回调的主要简化规则。 + +| 边界 | 所有权规则 | +|---|---| +| 同进程内的类型化服务/插件调用 | 借用 readonly 值和回调 | +| 解析的插件配置或外部文件 | 验证语义和结构输入 | +| 队列中的收件箱消息 | 在异步消费前物化 | +| 模型/工具 JSON 输入或输出 | 在模型/工具边界处物化 | +| 持久化会话或持久化数据 | 在提交前物化并验证 | +| Worker、进程或协议格式消息 | 序列化、验证并拥有解码后的值 | + +测试中构造恶意 getter、在交接后替换类型化回调、或强制转换伪造服务对象的做法本身不定义生产契约。运行时在数据跨越解析器、队列、模型、持久化、文件、worker、进程或协议格式(wire format)边界时保留检查,并在可信进程内依赖 readonly 类型加插件纪律。 + +回调隔离与数据所有权是分开的。监听器是任意扩展代码,即使其参数是可信的也可能抛出异常;发布和提交后路径仍按其事件契约隔离失败。 + +## 工具与 prompt:单一视图、权威组装、已提交的结果 + +工具展示和执行共享一个私有解析器。Prompt 组装仍然是可信的协作式组合:注册表提供有序输入,assembly waterfall 的返回值就是 agent loop(智能体循环)记录和发送的内容。执行仅在策略或结果结算必须单调时才使用独立的单向边界。 + +### 一个解析器定义工具视图 + +私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 + +[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 + +`ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。 + +### 工具执行拥有标识和边界物化 + +注册表为每次执行分配一个新的带品牌的 `Symbol` token。嵌套的 Code Mode 调用将外层 token 作为 `parent` 携带,因此结构化输出可以通过标识将内层捕获与其外层 `run_code` 结果关联。 + +注册表分配的新 Symbol 提供无碰撞的执行标识,无需 WeakSet 成员注册表。调用方无法通过 `ToolExecutionInput` 提供执行自身的 token;它们仅在注册表创建后接收流水线拥有的 `ToolExecution`。这是一个可信的类型化契约,而非针对任意强制转换或 JavaScript 调用方的运行时防御。 + +参数在模型/工具 JSON 进入流水线时一次性物化。Pre-、around- 和 post-execute 监听器操作类型化的 execution 和决策。Call ID 关联、审批、单调守卫和 Code Mode 嵌套仍然是显式的关系检查。 + +在最后一个 post-execute 监听器之后,注册表一次性物化并冻结被接受的最终结果。每个同步的 `tools/result` 观察者接收该确切的已提交对象,观察者失败被逐个隔离。外层流水线失败被规范化为已提交的错误结果,因此观察者可以丢弃针对同一权威边界的暂存工作。 + +### Assembly waterfall 拥有最终的模型可见组合 + +SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为确定性的注册表贡献。作用域过滤的 `system-prompt/assemble` waterfall 随后可以重排、替换、添加或移除任何段、变量或 schema。其返回的组装结果即为权威;没有后续的恢复步骤,普通 prompt 段、工具定义或提供方结果上也没有终态元数据。 + +这是一个可信的同进程扩展 seam,而非权限边界。修改 Code Mode 的 `run_code` schema 或 `tools:sdk` 指令,或结构化子级的捕获 schema 或指令的监听器,有责任在其返回的组装中保持协议的一致性。ToolRegistry 仍然保留 `run_code` 不受普通工具注册和限制影响,因为那些是注册表不变式,但 assembly 中间件仍然可以自由变换最终的模型可见表面。 + +Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 Code Mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 + +### 结构化输出仅提交权威结果 + +结构化输出将子作用域组合与两阶段执行提交相结合。子级在发布前注册其 `structured_output` 工具和指令;可信的 assembly 监听器可以变换这些普通贡献,并有责任在期望子级完成时保持协议。工具体验证候选值并按当前 `ToolExecution` 暂存,但成功捕获仅由不可变的 `tools/result` 观察决定。 + +对于原生调用,观察者仅在该确切执行的最终结果成功时才删除暂存并提交其值。因此 post-execute 阻止或外层流水线失败不会留下已捕获的值。 + +对于 Code Mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。 + +一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。提交后,普通串行的 `agent/turn-stop` 监听器在 continuation 和 steering(中途引导)已折叠之后返回停止决策。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。 + +纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 + +### 三个执行边界有意设为单向 + +Prompt 组装有意是协作式的,但三个执行事实在其可扩展阶段之后需要单向结算: + +| 边界 | 最终权力 | 为何普通监听器顺序不够 | +|---|---|---| +| 工具 pre-policy | 单调拒绝 | 后续监听器不得重新允许已被拒绝的调用 | +| 工具结果 | 观察不可变的已提交结果 | 结构化输出必须仅提交实际逃出流水线的结果 | +| 轮次 continuation | 在普通 continuation 折叠之后停止 | 已提交的终端输出必须结束轮次 | + +`ToolGuard` 是单调策略注册表。已提交的工具观察是上述被隔离的 `tools/result` 点。终端结构化输出监听普通串行的 `agent/turn-stop` 折叠,在正常 continuation 和 steering 决策之后;类型化的监听器契约不需要公开的 `strictSerial()` dispatcher。 + +### Skill 和 approval 服务信任类型化调用方 + +Skill 注册表定义和 approval 策略是 readonly 的同进程契约。它们的服务不克隆回调对象,也不防御交接后的回调替换。 + +Skill 仍然验证外部 skill 文件和解析的提供方输出,通过调用 agent 的工具视图路由目录,并精确 dispose 注册。Approval 仍然解析策略、观察取消、按 `request.agent` 路由 `approval/request`、记录持久化审计对,并隔离应答者和提交后观察者的失败。 + +## Subagent:就绪即 start promise + +Subagent 启动有一次所有权转移。提供方拥有部分资源直到其 start promise 以一个就绪的已发布 run 兑现;调用方拥有返回的 run 并必须 dispose 它。 + +### 服务契约有一个取消通道 + +`SubagentProvider.start()` 和 `SubagentService.start()` 返回 `Promise<SubagentRun>`。Promise 仅在后端建立了它所承诺的子级之后才兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` 就绪 promise。 + +`SubagentStartRequest.signal` 是必需的。中止它会在启动期间和就绪之后请求取消。`SubagentRun.dispose()` 也请求取消并等待静默。没有单独的公开 `run.cancel()` 通道。 + +可选的 `sendMessage()` 支持能接受 steering 的活跃后端。可选的 `resume()` 返回 `Promise<SubagentRun>`,因为恢复的子级有相同的异步就绪边界。 + +服务在调用提供方之前验证提供方能力和请求语义。提供方拒绝在拒绝逃出之前清理所有部分资源,且不发射 `subagent/start`/`subagent/end` 对。兑现之后,服务附加结果观察、发射作用域 start 并返回 run。提供方移除阻止后续 start,但不撤销提供方已接受的 run。 + +### 进程内提供方复用核心事务 + +Spawn 和 fork 共享一个进程内 driver。它通过 `parent.ctx` 创建子级,将必需的 signal 传入核心创建事务,并在未发布的 setup 期间安装 persona、工具限制和结构化输出贡献。 + +提供方等待创建并仅返回已发布的 run。在交接时,核心创建分离其仅用于创建的 abort 监听器;提供方在安装活跃 run 监听器之前立即重新检查 signal,因此在那个窄窗口中的 abort 会 dispose 新句柄而非逃脱取消。父级拆除跟随子级,因为操作属于 `parent.ctx`;提供方卸载阻止新 start 但不成为已接受 run 的第二个撤销所有者。Run disposer 取消子级并等待 AgentHandle 的有序拆除。 + +Spawn 使用空会话种子。Fork 使用经验证的已完成轮次前缀。对话种子仅改变历史,不导入作用域、工具、服务或权限。 + +### ACP 提供方拥有进程直到就绪或清理 + +ACP 提供方跨越真实的进程和协议格式边界,因此它保留验证、环境清洗、消息序列化、abort/进程竞争和 kill-to-exit 静默。 + +Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn 失败、RPC 失败或无效启动响应在拒绝前回收进程。就绪后,result 映射 ACP prompt 结果和流式输出;dispose 请求取消、关闭连接并通过一条记忆化路径等待进程退出。 + +## 工作流与 ACP UI:仅保留独立的异步事实 + +Worker 和编辑器桥接比同进程注册表需要更多状态,因为消息、进程死亡和渲染可以独立结算。它们的状态围绕这些真实事实组织,而非重复的取消协议。 + +### 工作流子级是待定 start 或已发布记录 + +工作流宿主保持待定的提供方 start promise 和已发布的子级记录。子级仅在异步 `SubagentService.start()` 兑现时才从待定变为已发布;被拒绝的 start 清理其部分提供方工作且不产生子级生命周期对。 + +一个宿主拥有的 AbortController 向待定和活跃子级提供必需的 signal。关闭工作流准入中止该 signal,因此没有重复的 `ChildCancel` worker RPC 或显式的宿主侧 `run.cancel()` 扇出。静默等待待定 start 和已发布子级 dispose 两者。 + +Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲裁、精确的子级计数、worker 死亡处理、优雅终止、迟到/重复消息拒绝和有界清理,因为结果接收、worker 退出和子级静默是真正独立的事实。 + +### 终端结果与物理清理保持分离 + +工作流结果按公开优先级规则记录首个被接受的终端结果。该结果选定后清理可以继续:活跃子级仍需 dispose,worker 仍需终止,慢速外部后端可能超出配置的优雅期限。 + +公开 dispose 在调用回调之前声明其记忆化 promise。Worker 死亡在处理任何排队的迟到子级请求之前关闭准入,合成缺失的生命周期结束,并启动子级/进程清理而不重写已声明的结果。 + +### ACP prompt 结算不依赖渲染成功 + +ACP UI 直接将 prompt 与其观察到的轮次关联。它不从 `logWatermark` 扫描,也不使用会话状态作为第二个调和预言机。 + +Prompt 处理在 transcript(文本记录)渲染的 `finally` 中结算关联。渲染失败可以导致展示失败,但不能跳过 prompt 结算或让会话永久处于进行中状态。对同一持久化的调用方提供的会话 ID 的并发加载仍被排除,因为那是真实的持久化标识竞争,而非 UUID 碰撞问题。 + +## 正确性强制 + +该设计通过类型、运行时逃逸点、生成的契约和行为测试来强制执行。没有哪一层被要求证明它无法观察到的东西。 + +### 类型使常规路径难以误用 + +Readonly 契约描述借用的同进程值。`Scoped<T>` 标记 event receiver,`agentEvents()` 融合载体和主体,工具输入省略注册表拥有的 token,subagent 异步返回类型直接暴露就绪性。 + +TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进程消息或持久化文件,因此运行时强制保留在这些逃逸点。 + +### 运行时不变式覆盖跨服务事实 + +不变式插件验证每个声明的作用域事件使用带标记的载体,以及暴露主体的事件族使用匹配的键。Session trace 验证在追加提交前暂存,并在同一事件提交后推进。 + +该插件不通过扫描注册表来管控可信 setup,也不拒绝通过强制转换构造的 prompt assembly 对象。这些检查会将组合契约变成推测性的运行时机制,却不保护真实的外部边界。 + +### 生成的产物使公开契约保持对齐 + +事件目录、服务目录、生产者/消费者矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 RFC](../process/2026-07-14-typescript-program-backed-semantic-gates.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 + +行为测试固定了作用域路由和 dispose、最终入口碰撞清理、发布回滚、有序静默、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 + +## 曾考虑的替代方案 + +[7 月 8 日 RFC](2026-07-08-agent-scope-contexts.md#alternatives-considered) 拥有公开扁平作用域契约的替代方案。此处的替代方案关注实现形态。 + +### 使用透明代理作为作用域载体 + +模拟主体的代理必须保持属性、可调用、可构造、私有字段、描述符和代理不变式行为,而监听器路由从不需要这些。一个小型不透明载体保持过滤器和键,而显式事件参数携带主体。 + +### 在 setup 前预留 agent 和 session ID + +预留防止重复的私有 setup 工作,但需要跨服务能力、释放排序、废弃预留清理和已准备对象绑定。ID 由调用方提供,并发复用是调用方错误;最终入口可以选择赢家,而失败的事务干净地回滚。 + +### 对每个类型化的同进程参数做快照 + +通用复制防御有状态 getter 和违反 readonly 契约的调用方,但增加分配、重复验证器和可能遗忘复制的路径。物化属于解析器、队列、模型、持久化、worker、进程和协议格式边界——即所有权真正变更的地方。 + +### 为就绪、取消和 dispose 提供独立控制器 + +并行哨兵可能都镜像一个操作是否活跃。一个事务或 start promise 拥有操作;独立 promise 仅在发布展开、外部工作、终端结果和物理静默可以独立结算时才保留。 + +### 保留同步 subagent start 加 `run.started` + +这将提供方接受与就绪分离,迫使每个消费方注册部分 run、附加结果观察、等待就绪并清理就绪失败。异步 start promise 使提供方到调用方的所有权转移本身成为就绪边界。 + +### 在 assembly 之后恢复选定的 prompt 或工具贡献 + +Waterfall 之后的恢复步骤会在文档化的协作式 seam 之后创建第二套组合规则。正确分配规范的存在或缺失还需要为任意工具 schema 提供方制定所有权和碰撞规则,而这些提供方的普通输出可能包含重复名称。作用域注册已经提供了所需的按 agent 隔离,可信的 assembly 监听器拥有其返回内容的协议一致性,因此命名恢复增加了机制却不建立独立边界。 + +### 用同进程加固替代 worker/进程生命周期守卫 + +Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化边界。首个结果仲裁、验证、环境清洗和静默进程清理即使在敌对的同进程回调机制不存在时仍然必要。 + +## 后果 + +实现更小,其证明与所有权图具有相同的形状。一个键选择一层,一条入口拥有一个活跃注册表对象,一个事务拥有创建,一个解析器拥有工具视图,一个异步 promise 转移 subagent 所有权。 + +### 设计保证的内容 + +- 作用域贡献仅在其精确的 agent 视图中可见,并随该作用域一起 dispose。 +- 创建和恢复不暴露部分配置的句柄;最终入口的失败者和发布失败清理每个已准备的资源。 +- Dispose 在 driver 排空和最终会话工作期间保留作用域监听器和持久化,然后撤销作用域。 +- 持久化、队列、模型、worker、进程和协议格式的值在其真实边界处被拥有;类型化的同进程值遵循 readonly 契约。 +- ToolRegistry 的展示、查找和执行在专家 assembly 变换之前解析相同的活跃视图,已提交的结果有一个不可变的观察点。 +- 注册表贡献是确定性输入,而可信的 assembly waterfall 拥有最终的模型可见组合。 +- Subagent start 仅返回就绪的 run,必需的 signal 取消待定或活跃的工作,dispose 到达后端的静默契约。 +- Worker/进程结果优先级和清理在死亡、迟到消息和有界拆除下保持正确。 + +### 代价与局限 + +作用域感知服务仍然维护全局和按标识键索引的映射,操作必须显式携带其真实 agent。异步创建/恢复和 subagent start 要求调用方等待所有权转移并 dispose 返回的句柄。 + +可信的 `system-prompt/assemble` 监听器可以移除或替换 Code Mode 和结构化输出协议片段。这是有意为之:监听器拥有最终组合,必须保持部署期望仍可用的任何协议。 + +该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 契约的修改,或插件有意在支持的组合 API 之外使用环境服务访问。 + +[安全与权限非目标](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升级。 diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml new file mode 100644 index 0000000000..8c97f0b5ee --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-06-sandbox.md: 29985b9336e7a190c90dc0e9c3b3aa78b6197e8e +2026-07-06-sandbox.zh.md: 4285ae4ceb81ee57dc3ab3d51467f9267743e06e diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index 28f2066ee9..29985b9336 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -1,5 +1,7 @@ # RFC: The subprocess sandbox — confinement seam, native runners, escalation, and per-session modes +English | [中文](2026-07-06-sandbox.zh.md) + Status: implemented ## Problem diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md new file mode 100644 index 0000000000..4285ae4ceb --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md @@ -0,0 +1,212 @@ +# RFC:子进程沙箱——约束 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`。范围有意限定:本 RFC 命名但不设计的阶段——按会话工作区根目录、跨工具族 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 RFC) + 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`)、`SandboxPolicy`(mode + workspace root)。 + +策略随每次**调用**而非提供方携带:两个消费方可以在同一时刻以不同策略约束(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` 然后 Landlock;macOS 使用 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 通过 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 交付,平台二进制由 npm 选择。该包(package)拥有路径解析、探测和 CLI flag;harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 + +FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together. + +后端 profile 共享模式契约但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 + +#### bash 消费方 + +`dsh-bash-sandbox` 复用本地进程执行,并请求 `ctx.sandbox` 包装确切的 bash argv。内核拒绝是独立于退出状态的结果事实,仅从所选包装的 stderr 方言推断。Runner 失败优先于拒绝,因为它意味着命令从未运行:前台调用抛出 `SANDBOX_UNAVAILABLE`,而已结算的后台任务为 `bash_output` 设置 `sandbox.runnerFailed`。这使损坏的约束与任务失败和强制拒绝保持区分。 + +模型看到的仅是结果事实:静态工具描述解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试;当升级字段被公布时,被拒绝的结果还额外携带升级提示本身,使被认可的同轮次重试在决策点被提示,而非依赖模型回忆描述(§ 升级机制)。没有提示词段落声明沙箱模式(§ 按会话模式)。 + +#### 升级机制:拒绝后一次经批准的更宽重试 + +`BashExecRequest.sandboxMode` 是可选的按调用输入;解析后的 spec 使该字段显式。`BashExecutor.sandboxMode` 公布已挂载的执行器能否兑现它,因此只有约束组合才暴露升级。seam 接受任何显式模式;工具拥有「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。 + +`SandboxBashExecutor.resolve()` 盖章有效模式——升级授权 > 会话覆盖 > 配置默认——使 `run()`/`start()` 读取 spec 而非配置。`danger-full-access` 分支、confine 调用和结果事实都以 spec 的模式为键,且按任务的事实 map 携带每个任务的模式及其包装事实(`notifyTaskDone()` 从 map 条目盖章):一次升级调用——前台或后台——报告它**实际**运行的模式,而每个邻居保持自己的。 + +当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。 + +升级是对被拒绝命令的同轮次重试,使用最窄的足够 `sandbox_permissions` 和一个 `justification`;批准提示是同意步骤。它必须基于实际的拒绝,除非会话已观察到相同的被拒绝访问;禁用或被拒绝的批准终结该命令。重试、批准决策和结果使用既有的工具和批准事件。`dsh-tool-bash` 拥有请求动作,因为执行器 seam 既没有 agent 也没有用户交互所需的 call id。 + +留待后续阶段处理:授权的范围标识超出沙箱模式之外是什么——确切的调用、路径、命令前缀、会话、时间窗口——这是 `allow_always` 授权存储在该选项可被公布之前必须回答的问题;以及如何为通过 `bash_output` 延迟到达的 `run_in_background` 拒绝定义升级。 + +#### 按会话模式:会话日志即存储 + +``` +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 { + 'bash/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'` 门控是[批准 RFC](2026-07-06-approval-seam.md) 同一模式的另一侧。 + +沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'never'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个 pre-step 递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的。 + +**编辑器界面**是协议原生的 [Session Config Options](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 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom`。`session/set_config_option` 通过 permission 服务验证并切换,然后返回完整的刷新状态(规范契约)。 + +**轮次封闭是提交边界。**开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定,在下一次 prompt 提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`,setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。 + +#### 进程内工具 + +fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层面的策略:fs 意图门控按共享模式词汇决策(§ 延迟阶段,跨工具族),使 `read-only` 成为真正的边界而非仅限 bash 的近似——在此之前契约诚实地如此声明。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装。 + +FIXME: Revisit this tool-local boundary. The follow-up design needs to determine whether sandboxing becomes a global harness capability that applies uniformly to every tool, instead of expressing in-process enforcement independently at each tool seam. + +### 测试 + +- **单元测试:**固定平台选择和 profile、失败关闭的 runner 分类、按调用事实、升级验证和结果、permission preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。 +- **Keyless 真实 runner:**在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定 permission 切换并拒绝未知 preset。CI 拒绝静默全跳过。 +- **With-key:**驱动真实模型、runner、bridge 应答器和磁盘效果通过授权和拒绝的升级;不可用的凭证或 runner 自动跳过。 +- **快照:**固定 permission config-option 协议格式(wire format)、preset 和旋钮事件、prompt delta 和通知、以及两个脚本化的 approval 分支。快照模式以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。真实拒绝 stderr 留在平台测试中,因为其方言是 runner 特定的。 + +## 延迟阶段 + +每个阶段在被拾起时获得完整设计,对照当时的代码验证,并在其涉及的层级带上单元测试、真实 API e2e 和快照覆盖率落地。 + +- **按会话工作区根目录**——执行器的写入边界在其生命周期内保持配置固定,而每个 ACP 会话有自己的 cwd;按会话根目录一旦设计完成即搭载同一个按调用策略载体。 +- **跨工具族边界**——fs 意图门控按共享模式决策,使 `read-only`/`workspace-write` 成为 bash 之外的真正边界。 +- **第二个消费方**——`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`,没有可附加 prompt 的 `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 和 sandbox 独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 +- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/prompt-submit`),而 pre-step 的位置以一个监听器同时服务合并的轮次入口通知和轮中即时性约束。 +- **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝**尝试**被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。 +- **用专门的簿记事件追踪「上次告知」**:否决。`request/header*` fold 已记录模型看到的确切 prompt;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们**本身即为存储**时才需要。 +- **ACP session modes 而非 config options**:否决。preset 已经是一个部署定义的 config-option 选择器,且 modes 计划在 ACP v2 中移除。 + +## 后果 + +已交付并固定的内容——测试中的各层级分别保障: + +- 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使**该次**调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。 +- 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。 +- 系统提示词从不声明沙箱模式(批准 `'never'` 策略是唯一被声明的旋钮),且整个交互——header、旋钮事件、通知、批准、结果——仅从会话日志即可重建,除两个旋钮事件外无额外事件类型。 +- N 次空闲切换每个旋钮最多产生一个锚定事件(净零序列不锚定任何事件——客户端回显当前选择的无操作推送不记录任何内容);批准策略切换最多以一条合并通知叙述;轮中沙箱切换由下一次调用的盖章兑现。 +- 恢复的会话的覆盖生效并报告给编辑器,无需特殊处理;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。 +- 两个并发会话永远看不到彼此的状态、通知或配置选项。 +- `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/pre-step`、`agent/prompt-submit` 和 ACP handler 表面。 + +代价与已接受的限制: + +- **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加 prompt 约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 +- **`read-only` 尚不是跨工具族边界。**在 fs 意图门控按共享模式决策之前,该声明仅对 bash 成立;契约诚实地如此声明(§ 进程内工具)。 +- **Windows 没有后端。**其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 +- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。**作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。 +- **Landlock 约束的完整度取决于运行内核的 ABI。**报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 +- **launcher 作为注册表依赖到达。**通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 +- **模型可能过度请求。**在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的 prompt 是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 +- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 +- **授权的升级不等于可工作的沙箱。**不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 +- **空闲切换存在于 bridge 内存中,直到下一次 prompt 提交锚定它。**该窗口内的崩溃将其回退(在 `session/load` 时报告),且永不再提交 prompt 的会话永不持久化它——已接受,loop 拥有的空闲提交轮次留作未来工作(如果持久性成为需求)。 +- **批准叙述器的重启基线解析 prompt 文本。**封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑;header 早于该段落的会话静默采用当前策略而不发通知。 +- **批准段落仍是动态 prompt 表面**(`'never'` 切换会破坏该会话的提供方 prompt 前缀缓存)。已接受:策略切换罕见,且模型基于过时的 `'never'` 行动更糟。沙箱旋钮不再触及 prompt。 +- **模型可能持有关于沙箱模式的过时信念**(没有任何东西宣布切换)。有意接受:下一次尝试的标记或成功会纠正它,而宣布的观察到的失败模式——预防性拒绝——比一次浪费的重试更糟。 + +## 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 工具,以及传递性的钩子命令。fs/web/todo 在进程内执行,`execve` 包装对它们机械上毫无意义;它们的 `read-only` 语义随跨工具族延迟阶段到来,在此之前契约诚实地声明仅限 bash。 +- **授权的升级会持久化吗?或覆盖后台任务吗?**都不会:授权被请求它的那次调用(前台或后台)消耗,该次调用报告它实际运行的模式,而每个邻居保持自己的。如何为通过 `bash_output` 延迟浮现的后台拒绝**定义**升级,留在 § 升级机制中开放。 +- **编辑器的模式切换何时生效?**轮中:立即追加,由下一次调用的盖章兑现。空闲:保持在 bridge 的会话记录上,在下一次 `agent/prompt-submit` 时锚定到其开放轮次中,N 次切换合并为最多一个事件(净零则无);锚定前崩溃回退它,`session/load` 报告真实状态。模型不被告知——其下一个命令直接在新模式下运行。 +- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?**覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值以与切换相同的方式改变行为(批准策略因被声明,还额外以运维人员/配置归因叙述)。 +- **结果上的 `enforcement: 'partial'` 是什么意思?**所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`。 + +## 先例 + +本设计复制或对比的仓库内先例: + +- [能力 seam RFC](../architecture/2026-06-13-capability-seams.md)——接口/实现/消费方拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 +- `dsh-bash` 的 request/spec 拆分及其 `owner` 字段([bash 词汇目录](../../../core-data-structures/bash.md))——`sandboxMode` 搭载的按调用载体模板,以及显式 `resolve()` 默认约定。 +- [批准 seam RFC](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 RFC](2026-06-30-interception-seams.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index c836ec18de..0459512d1e 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -73,6 +73,7 @@ "docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md", "docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", + "docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md", "docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md", "docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md", "docs/rfc/implemented/feature/2026-06-15-code-mode.md", @@ -92,6 +93,7 @@ "docs/rfc/implemented/feature/2026-07-05-skill-system.md", "docs/rfc/implemented/feature/2026-07-06-approval-seam.md", "docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md", + "docs/rfc/implemented/feature/2026-07-06-sandbox.md", "docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md", "docs/rfc/implemented/feature/2026-07-07-session-prefix.md", "docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md", From 3fd5d5dc2b895f0e2ee0e4db0de66dfdd648ea3b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:11:02 +0800 Subject: [PATCH 011/113] docs(i18n): address core translation review --- docs/defensive-patterns.i18n.yaml | 4 ++-- docs/defensive-patterns.zh.md | 2 +- docs/glossary.i18n.yaml | 4 ++-- docs/glossary.zh.md | 30 +++++++++++++++++++++++++++--- docs/i18n/README.i18n.yaml | 4 ++-- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- docs/testing.i18n.yaml | 4 ++-- docs/testing.md | 6 +++--- docs/testing.zh.md | 23 +++++++++++++++-------- 10 files changed, 56 insertions(+), 25 deletions(-) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 96bb5de13f..b39cad3e24 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -defensive-patterns.md: fda0be0d2d3b7fa099162123b3d219673eebd07d -defensive-patterns.zh.md: f6e4712a4a239c954193f63f32285037eb6d4f0e +defensive-patterns.md: 349b916df6f7544300dacd578acf42668d9436ac +defensive-patterns.zh.md: 19565f54595195a52d1b49ff487294945171ae2d diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index f6e4712a4a..19565f5459 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -14,7 +14,7 @@ ## 异步状态不是同步状态 -`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而非计数你假定与轮次一一对应的操作(循环会批量处理排队消息)。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 +`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作单次发送的结果:多个排队发送会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 ## Dispose 必须达到静止,而不仅仅是请求停止 diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index fe099eb156..6352220975 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -glossary.md: 23eebc5793e8232482a796f5bde1e794f0556208 -glossary.zh.md: a233f926ba3af0a4e90de90bf21d68a2244c3ea6 +glossary.md: c1931c0e0c630d05f5bd4fc3f30720f858f1175e +glossary.zh.md: f8f3c9489a8be94de7fb3f021dd70bea7a399d73 diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index a233f926ba..f8f3c9489a 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -2,9 +2,9 @@ [English](glossary.md) | 中文 -DeepSeek Harness SDK 的领域词汇对每个概念使用唯一的规范术语。各术语通过标准 Markdown 锚点互相链接;实现细节留在各 package README 与 RFC 中。 +DeepSeek Harness SDK 的领域词汇为每个概念规定一个规范术语。各术语通过标准 Markdown 锚点链接到相应条目;实现细节留在各包(package)的 README 与 Agent Note(agent 决策记录)中。 -FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. +FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 SDK 的其他核心与能力子系统,而非仅限于 agent scope。 ## agent-scope @@ -16,4 +16,28 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **shadowing**:最具体者胜出的名称解析:一个有范围的工具/片段/变量仅在该 scope 内替换同名的全局对应项。这是按 agent 定制 persona 和按 agent 定制工具变体的机制。 - **restriction / scope-local 注册**:restriction(`tools.restrict`)为单个 scope 过滤全局工具表面(多个 restriction 取交集组合);scope-local 注册在过滤之后合并。被过滤掉的全局工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分。 - **setup window**:创建者组装 agent 有范围世界的创建时隙(`CreateAgentOptions.setup`):在 scope 和 agent 对象已存在、但 agent 或会话尚未发布、`agent/session-start` 尚未触发、首次提示词尚未组装之前。setup 只做注册,从不驱动 agent。 -- **lineage**:以数据形式携带的父子关系事实(`parentSession`、`subagentDepth`);从不影响可见性。<a id="lineage"></a> +- **lineage**:以数据形式携带的父子关系事实(`parentSession`、持久的 `delegationDepth`、运行时 `subagentDepth`);从不影响可见性。<a id="lineage"></a> + +## 目标 + +- **目标**:附着在现有会话上的单个持久完成目标,带有按修订号演进的 `active` / `paused` / `blocked` / `complete` 阶段和目标回合上限;`blocked` 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。 +- **目标回合**:为当前目标接纳的一次续行周期。同会话驱动器将目标回合具体化为一个来源为目标的[轮次](#turn),其中可以包含多个步骤;同一会话中无关的人类轮次不消耗目标回合上限。<a id="goal-round"></a> +- **目标激活**:续行消费方接纳下一个目标回合的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此恢复和 fork 后,必须由人类随后通过 `/goal` 或模型工具授权恢复变更,自动工作才可开始。 + +## 人类命令 + +- **人类命令**:以斜杠开头的指令,由面向人类的适配器通过 `ctx.commands` 解释并执行,不会成为模型消息。它既不同于面向模型的工具,也不同于通过 `ctx.bash` 执行 shell 命令。 +- **命令平面**:由 UI 适配器与命令插件拥有的发现、解析、分发、取消和结果渲染。除非处理器另行改变持久领域,否则命令输出属于 UI 状态。 +- **目标命令**:`/goal` 是由 `dsh-command-goal` 提供的人类命令;它直接观察或更改当前目标,而目标领域拥有每条持久且模型可见的记录。 + +## 循环层级 + +- **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a> +- **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含一个或多个步骤。<a id="step"></a> +- **回合**:包含一个轮次的外层策略迭代,例如一个[目标回合](#goal-round)或一次全新 agent Ralph 尝试。回合计数器属于该策略,并不统计会话内的每个轮次。<a id="round"></a> + +## Ralph + +- **Ralph 循环**:一次面向不可变目标的前台全新 agent 工作流运行。它是由工作流和 subagent 原语组合而成的面向模型的工具策略,不是同会话目标、agent loop(智能体循环)模式、调度器或通用工作流脚本功能。<a id="ralph-loop"></a> +- **Ralph 回合**:[Ralph 循环](#ralph-loop)中的一个全新子会话。子会话不接收父会话或此前子会话的对话种子;共享工作区和一份有界的 [Ralph 交接](#ralph-handoff)承载跨回合状态。<a id="ralph-round"></a> +- **Ralph 交接**:从一个仍需继续的 Ralph 回合传给下一回合的规范化、有界结构化报告,包含状态、摘要、证据、后续步骤和阻塞说明。它补充共享工作区,而不取代工作区的权威地位。<a id="ralph-handoff"></a> diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 2a72aaa53e..224c3e6314 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: c4ddf44ad2497b4ff371918356ab1ec0698c7049 -README.zh.md: 4a31af4fdee4db2d0362cf9117a6eef4fea32393 +README.md: 16c036c98176f5410fe24fc24348177c84ad47a6 +README.zh.md: b37eeea99564d602523a4ebe379de38750dbc1ba diff --git a/docs/i18n/README.md b/docs/i18n/README.md index c4ddf44ad2..16c036c981 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -40,7 +40,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): -- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, and `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. +- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. - `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 4a31af4fde..b37eeea995 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -40,7 +40,7 @@ **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): -- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md` 与 `docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 - `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md)——二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md)——自动翻译流水线的 prompt 模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index e91d6712ea..8ef18d76b6 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -testing.md: ddb9da0b38e5dc9cc75ede81ec157c4744fd11c2 -testing.zh.md: 8a37a9eaffbf19b205e3b98e7609464133060cf2 +testing.md: 1de3a0754dadbad1f36a9fc8c19b8ba77071bd54 +testing.zh.md: c557dfd91bca436a9c969cde6e2a0b24237e3146 diff --git a/docs/testing.md b/docs/testing.md index 22a678a230..1de3a0754d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,15 +9,15 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external presentation. ACP boots the real example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). ## The with-key policy: inference is cheap here -We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships both a keyless smoke and a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). +We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Cover file-writing prompts, multi-turn conversations, tool use, and mid-stream cancellation. Highest-value are **smoke tests** that boot the real example, send one prompt, and check the world — they catch the "green unit tests, broken product" class that mocks cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). Self-skip keeps secretless CI and keyless contributors unblocked; it is not a cost signal. Every example ships keyless and with-key smokes ([examples/AGENTS.md](../examples/AGENTS.md)). ## Prefer the real implementation over a mock -Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. Example: bridge tool-call tests run the scripted mock MODEL but the real tool + real executor (`makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`). +Mock only the expensive or non-deterministic boundary (LLM adapter, network, clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted. Bridge tool-call tests use the scripted mock model with the real tool and executor: `makeBridgeHarness({ withBash: true })` plugs in `dsh-bash-local` and `dsh-tool-bash`, then runs `echo`. Recovery tests separate pre/post-chunk failures by step and prove failed chunks derive no message or tool side effect. Cover exhaustion, cancellation, policy composition, persistence, status, wire counts, transport-closing idle timeouts, and shipping Loader composition. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 8a37a9eaff..c557dfd91b 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -2,22 +2,24 @@ [English](testing.md) | 中文 -本文说明本仓库的分层测试方式,以及保持绿色测试套件有意义的规则。命令见根目录 [AGENTS.md](../AGENTS.md);关联的 RFC 承载设计动机。 +本文说明本仓库的分层测试方式,以及保持绿色测试套件有意义的规则。命令见根目录 [AGENTS.md](../AGENTS.md);相关 Agent Note(agent 决策记录)承载设计动机。 ## 层级 - **单元测试**(`pnpm run test`):vitest 运行 `packages|examples/*/tests/**/*.spec.ts`,与被测代码同目录。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 -- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试,调用真实提供方 API。包括 DeepSeek 模型以及各提供方特有的冒烟测试(各自依赖自己的密钥:`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等);缺少密钥时各套件自动跳过,keyless CI 保持绿色([真实 API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照测试**(`pnpm run test:snapshot`):启动真实示例子进程,在无密钥环境下回放录制的会话,将归一化的 stdout 与重新持久化的日志与已提交的 golden 文件做 diff([快照 RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md))。当模型 transcript(文本记录)需要变更时使用 `pnpm run test:snapshot:record`;当已提交的 transcript 仍是正确的 mock LLM(大语言模型)输入、只需无密钥重写回放 golden 时使用 `pnpm run test:snapshot:refresh`。请审查 golden diff。系统提示词/工具 schema 内容由**一个**场景(`text-turn`)固定,其余 fixture(测试前置数据)中以 token 化形式引用,因此 prompt 或 schema 的修改只影响一行已提交内容([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外呈现。ACP 启动真实示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包(package)级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 ## 带密钥策略:推理在这里很便宜 -我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明管道通畅;只有带密钥运行才能证明 agent(智能体)在真实模型面前能正常工作。请大量编写:文件写入 prompt、多轮次对话、工具调用、流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条真实 prompt、检查外部世界的状态。它们能捕获「单元测试全绿、产品却坏了」这一类 mock 在结构上无法发现的问题([事后分析 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过机制的存在仅仅是为了不阻塞无密钥的 CI 和无密钥的贡献者,它不是成本信号。每个示例都附带一个 keyless 冒烟测试,并且——除非本身就不需要密钥——还附带一个带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 +我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、多轮对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 ## 优先使用真实实现而非 mock -只在真正昂贵或不确定的边界处 mock(LLM 适配器、网络、时钟);下游一切保持真实。手写的替身只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言——两者会漂移,而测试继续绿着。例如:bridge 工具调用测试运行脚本化的 mock 模型,但使用真实的 tool + 真实的执行器(`makeBridgeHarness({ withBash: true })` 接入 `dsh-bash-local` + `dsh-tool-bash` 并执行真正的 `echo`)。 +只 mock 开销高或不确定的边界(LLM(大语言模型)适配器、网络、时钟);下游一切保持真实。手写替身只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言。桥接工具调用测试将脚本化 mock 模型与真实工具和执行器配合使用:`makeBridgeHarness({ withBash: true })` 接入 `dsh-bash-local` 与 `dsh-tool-bash`,然后运行 `echo`。 + +恢复测试按步骤区分分片前与分片后的失败,并证明失败分片不会派生出消息或工具副作用。覆盖耗尽、取消、策略组合、持久化、状态、协议计数、会关闭传输的空闲超时,以及交付的 Loader 组合。 ## 验证外部世界,而非自我报告 @@ -27,9 +29,14 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 - 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部/不确定边界,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 - 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 -- 「真实入口路径」指已发布的产物:package 的 `bin` 指向在普通 `node` 下运行的构建产物 `lib/bin.js`,tsx 会掩盖问题(竞态、模块解析、吞掉的加载失败以 exit 0 退出)。同样适用于构建后的 package 在运行时解析的任何非 index 运行时入口(worker-thread 运行时的兄弟文件 `lib/worker.cjs`)。保持构建产物冒烟测试绿色(`packages/ui/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零退出码退出。 -- 从临时 cwd spawn 示例的 e2e 测试需要设置 `TSX_TSCONFIG_PATH` 为仓库根目录的 tsconfig,否则会静默回退到陈旧的构建产物 `lib/`([examples/AGENTS.md](../examples/AGENTS.md))。 +- 「真实入口路径」指已发布的产物:包的 `bin` 所运行的是构建后的 `lib/bin.js`,并由普通 `node` 执行,从而暴露 tsx 会掩盖的失败(等待稳定时的竞态、模块解析、被吞掉的加载失败)。同样的规则适用于非 index 运行时入口(worker-thread 的同级文件 `lib/worker.cjs`),也适用于多个 bundle 共享的单例模块(`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`)。保持构建产物冒烟测试绿色(`packages/ui/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零状态退出。 + +## 测试子进程启动模式 + +- CI 与已有构建产物的测试通道通过共享双模式启动器,从构建后的 `lib/` 运行每个示例或 Cordis 配置子进程。不要为这些子进程手写 `--import tsx`。 +- 不加载 Cordis 的协议与操作系统 fixture 直接通过 Node 运行使用可擦除语法的 `.ts` 文件,不经过 tsx 或根路径映射。 +- 只有测试对象本身是源码路径解析时,才可以选择 `src`;在测试中写明这一契约。 ## 何时需要快照测试 -任何影响编辑器侧 transcript 或端到端 agent UX 的变更——ACP bridge、agent loop(智能体循环)的可观测输出、工具呈现——都需要在所属示例的快照套件中添加或更新场景(`examples/<name>/tests/snapshots/`,基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表;`examples/acp-agent` 是主套件),或在 PR 中说明为何不适用。新的能力 seam、生命周期形态或 transcript 表面在计划阶段就要列出各层级的覆盖方案,并验证 harness 能够表达它——harness 的缺口是排期工作,不是构建中途的意外。 +每项非平凡的模型可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 接口使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `examples/tui-agent/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 From f72db5ac7b42e82ccc19f8d2c427828829d2a941 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:19:12 +0800 Subject: [PATCH 012/113] docs(i18n): address core data review --- docs/core-data-structures/bash.i18n.yaml | 2 +- docs/core-data-structures/bash.zh.md | 2 +- .../code-runtime.i18n.yaml | 2 +- docs/core-data-structures/code-runtime.zh.md | 2 +- .../core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- docs/core-data-structures/core.i18n.yaml | 2 +- docs/core-data-structures/core.zh.md | 2 +- .../core-data-structures/filesystem.i18n.yaml | 2 +- docs/core-data-structures/filesystem.zh.md | 4 +- .../llm-streaming.i18n.yaml | 2 +- docs/core-data-structures/llm-streaming.zh.md | 2 +- docs/core-data-structures/scope.i18n.yaml | 2 +- docs/core-data-structures/scope.zh.md | 2 +- docs/core-data-structures/session.i18n.yaml | 2 +- docs/core-data-structures/session.zh.md | 4 +- docs/core-data-structures/subagent.i18n.yaml | 2 +- docs/core-data-structures/subagent.zh.md | 6 +- docs/core-data-structures/tools.i18n.yaml | 2 +- docs/core-data-structures/tools.zh.md | 6 +- docs/core-data-structures/web.i18n.yaml | 2 +- docs/core-data-structures/web.zh.md | 2 +- docs/core-data-structures/workflow.i18n.yaml | 2 +- docs/core-data-structures/workflow.zh.md | 2 +- ...-acp-default-export-drops-inject.i18n.yaml | 2 +- ...0001-acp-default-export-drops-inject.zh.md | 8 +- ...ession-disabled-filesystem-tools.i18n.yaml | 2 +- ...expression-disabled-filesystem-tools.zh.md | 2 +- docs/postmortem/README.i18n.yaml | 2 +- docs/postmortem/README.zh.md | 10 +- docs/rfc/README.i18n.yaml | 2 +- docs/rfc/README.zh.md | 8 +- scripts/type-equiv.manifest.json | 130 +++++++++++++++++- 33 files changed, 180 insertions(+), 46 deletions(-) diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index eba38e8f77..601c350359 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write bash.md: 7b5c779b832ef5be6591e626980f7f22db54f239 -bash.zh.md: 8a209b4853e44d1846460c41f4d5b9a43082a65b +bash.zh.md: c4d27efa8344727e3a77c928b331ec570c2716eb diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 8a209b4853..c4d27efa83 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -112,7 +112,7 @@ interface BashExecSpec { 受信的进程内插件使用 `stdin` 和 `env` 传递钩子载荷与钩子专用变量。面向模型的 bash 工具从其命名的 schema 字段构造请求,不暴露这两个输入,因为 shell 语法本身已提供等价能力;测试防止未来出现 `...args` 展开。这是请求形状的纪律约束,而非安全边界:`dsh-bash-local` 无论这些字段如何都会清洗环境凭证,然后叠加调用方已持有的显式值。见 [bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 -该 seam 处理的两个 id 都是[品牌化的](core.md)(零成本 `string` 品牌,与 `SessionId`/`AgentId` 相同的机制):`BashTaskId`(被跟踪的后台任务,由本地执行器生成 `bash-N`)和 `OwnerToken`(不透明的隔离键)。`OwnerToken` 刻意是一个与 `SessionId` **不同**的品牌,而非别名:bash seam 是一个能力 seam,不得知道 owner token *意味着*什么,因此它从不导入 `dsh-session` 的词汇。`dsh-tool-bash` 消费方是唯一将拥有者 agent 的 `SessionId` 转换为 `OwnerToken` 的边界。对两者施加品牌化,可以防止裸 `string`(或在需要 `OwnerToken` 的地方传入 `BashTaskId`,反之亦然)在面向模型的 `task_id` 路径上通过类型检查。 +该 seam 处理的两个 id 都是[品牌化的](core.md)(零成本 `string` 品牌,与 `SessionId`/`AgentId` 相同的机制):`BashTaskId`(被跟踪的后台任务,由本地执行器生成 `bash-N`)和 `OwnerToken`(不透明的隔离键)。`OwnerToken` 刻意是一个与 `SessionId` 不同的品牌,而非别名:bash seam 是一个能力 seam,不得知道 owner token *意味着*什么,因此它从不导入 `dsh-session` 的词汇。`dsh-tool-bash` 消费方是唯一将拥有者 agent 的 `SessionId` 转换为 `OwnerToken` 的边界。对两者施加品牌化,可以防止裸 `string`(或在需要 `OwnerToken` 的地方传入 `BashTaskId`,反之亦然)在面向模型的 `task_id` 路径上通过类型检查。 ## 前台运行:`BashRunResult` diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index 7c9ef2a7a3..918b3f5738 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write code-runtime.md: 28152947d0853fb10228c472ca3e121e77b7b598 -code-runtime.zh.md: 8270d12e7cce8c3b2443da9de268d95cf9d692d8 +code-runtime.zh.md: 7d5750e0c245ba97ed7ece6083464382109e10fd diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index 8270d12e7c..7d5750e0c2 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -82,4 +82,4 @@ interface CodeRunFailure { ## 服务 -`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,`'typescript'` 是已知值;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),且 dispose(资源释放)至静默:进行中的运行在 teardown 完成前被终止并等待结束。 +`CodeRuntime`(`ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,`'typescript'` 是已知值;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose(资源释放)时等待系统完全停稳:teardown 完成前,进行中的运行都已终止并等待结束。 diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index a0a32275e6..f1673efb53 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: e82cc103932ad05e68bc5311dec23c3f2c1a7ce4 -compaction.zh.md: ad8b524a1984357b5bb911b59300a194407bdbb9 +compaction.zh.md: bae6e9aa385efe0a7722c2b360c0b88e87a1a779 diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index ad8b524a19..bae6e9aa38 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -8,7 +8,7 @@ ## `compact/*` 会话事件 -上下文压缩通过声明合并为 [`SessionEventMap`](session.md) 扩展了三种事件类型。三者均为**仅日志**事件:它们记录压缩锁及其来源信息,永远不进入 surface。`SurfaceEventType` 被刻意**不**扩展(只有产生消息的事件才到达模型),因此摘要本身搭载在一条独立的 `user/message` 上,带有 `surfaceOp: { op: 'replace', start, end }`——唯一的 surface 变更。关于为何复用 `user/message` 是诚实的做法而非权宜之计,见 RFC。 +上下文压缩通过声明合并为 [`SessionEventMap`](session.md) 扩展了三种事件类型。三者均为**仅日志**事件:它们记录压缩锁及其来源信息,永远不进入 surface。`SurfaceEventType` 被刻意不扩展(只有产生消息的事件才到达模型),因此摘要本身搭载在一条独立的 `user/message` 上,带有 `surfaceOp: { op: 'replace', start, end }`——唯一的 surface 变更。关于为何复用 `user/message` 是诚实的做法而非权宜之计,见 RFC。 | 事件 | 载荷 | 作用 | |---|---|---| diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 8017a93ecd..a1eaa5e393 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write core.md: 7ace373ae549b86f21151610719940100d6348d0 -core.zh.md: d6467c12e1d64624357fcb8b37688b484bc23b0c +core.zh.md: 0212dee3587d8a67bcd484a67f078d7195f6eef7 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d6467c12e1..0212dee358 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -13,7 +13,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** 2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 -其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节。*因此 `ToolDefinition` 是核心,但为它提供类型推导的 `SchemaSpec`/`InferArgs` DSL、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 +其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `SchemaSpec`/`InferArgs` DSL、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 | 子页面 | 负责内容 | |---|---| diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 4e3d400339..843abd6dc6 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write filesystem.md: 8bdc2323a0bf63588e01520926f093538fee4912 -filesystem.zh.md: 1b636dd35e3c614d87973c617ea052058995e0e0 +filesystem.zh.md: 86b18af783899ed857f059b6b7cdb740ea357798 diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 1b636dd35e..86b18af783 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -121,7 +121,7 @@ interface FileReadOutcome { ## 已观测文件状态(策略插件) -已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, { version }>>`。当且仅当所有者已读取、写入或编辑过该目标时(每次成功都 emit `fs/observed`),条目才存在,因此其存在本身就是先前观测的记录——没有单独的 `hasRead` 标志,也没有视图区分。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。成功的 read/write/edit 会刷新该所有者对应的已记录版本;dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全)。 +已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap<owner, Map<targetKey, { version }>>`。**当且仅当**所有者已读取、写入或编辑过该目标时(每次成功都 emit `fs/observed`),条目才存在,因此其存在本身就是先前观测的记录——没有单独的 `hasRead` 标志,也没有视图区分。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。成功的 read/write/edit 会刷新该所有者对应的已记录版本;dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全)。 ## 错误分类体系(提供方 seam) @@ -146,4 +146,4 @@ type FsErrorCode = ## 服务与插件 -`FileSystem`(`ctx.fs`,抽象)拥有提供方原语:`resolve`、`stat`、`readText`、`streamText`、`listDir`、`writeText` 和 `editText`。`dsh-fs-policy` 不注册任何服务——它是一个通过 `fs/*` 事件门控叠加策略的插件:它裁决 write/edit intent waterfall(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。生成的接线目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 中展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,抽象)拥有提供方原语:`resolve`、`stat`、`readText`、`streamText`、`listDir`、`writeText` 和 `editText`。`dsh-fs-policy` **不注册任何服务**——它是一个通过 `fs/*` 事件门控叠加策略的插件:它裁决 write/edit intent waterfall(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。生成的接线目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 中展示确切的 `ctx.fs` 签名。 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index f9cd711351..b06be9dfa6 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write llm-streaming.md: ffd276b4647be8d10afcab0fb3c3f6daad790d20 -llm-streaming.zh.md: 4cb87c9fe65e264f9ce2f39d6ea3b5c6183d5cc1 +llm-streaming.zh.md: ea11f47e1148d21c2f23a648002c9413103628f1 diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 4cb87c9fe6..ea11f47e11 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -23,7 +23,7 @@ type StreamChunk = ## 适配器契约 -每个适配器**必须**遵守以下规则,每个消费方可以依赖它们: +每个适配器必须遵守以下规则,每个消费方可以依赖它们: - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 diff --git a/docs/core-data-structures/scope.i18n.yaml b/docs/core-data-structures/scope.i18n.yaml index a9467c71da..6d3acddaff 100644 --- a/docs/core-data-structures/scope.i18n.yaml +++ b/docs/core-data-structures/scope.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write scope.md: f95594329ee9ac83da2efcc31df377c53e64331a -scope.zh.md: 277fa4ec5c365e5ff3ee6d9baeae525d3dfc9f96 +scope.zh.md: be423e62def89e03f15d438b9fd9db6fd41b68f3 diff --git a/docs/core-data-structures/scope.zh.md b/docs/core-data-structures/scope.zh.md index 277fa4ec5c..be423e62de 100644 --- a/docs/core-data-structures/scope.zh.md +++ b/docs/core-data-structures/scope.zh.md @@ -22,7 +22,7 @@ type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } ## 拥有所有权的注册上下文 -`Scope` 将带标签的注册上下文与两个拆卸接口配对。`rawDispose` 保留有序复合 effect 所需的精确 Cordis disposer 身份;`dispose()` 是面向直接调用方和竞争调用方的公共静默边界,用于 dispose(资源释放)。 +`Scope` 将带标签的注册上下文与两个拆卸接口配对。`rawDispose` 保留有序复合 effect 所需的精确 Cordis disposer 身份;`dispose()` 是面向直接调用方和竞态调用方的公共停稳边界。 ```ts type-equiv interface Scope { diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 23b16e8d66..33e1d86f7d 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session.md: 796abebbc31a54c7c341028cf0a09031ae59cd78 -session.zh.md: b55ff3b7fe9c3a5f65dc46addf266dc1e5430116 +session.zh.md: 04bfe84f079762091e7c4a4d77db69c053e6ea30 diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index b55ff3b7fe..04bfe84f07 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -122,7 +122,7 @@ export interface EpochHeader { } ``` -规范形式:空的系统提示词、空的工具列表和空的会话前缀均为 ABSENT 字段,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix` + 派生历史);每个 agent loop(智能体循环)实例组合一次,由该实例的快照锚定,因此实际上 loop 不会产生前缀 delta。delta 分支(整数组替换,空数组编码「回到无前缀」的转换)存在是为了编解码的完备性。其他 delta payload(`SystemDelta`:公共前缀/后缀行裁剪;`ToolsDelta`:按名称键控的增/删/改)与事件一起定义在 [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)。 +规范形式:空的系统提示词、空的工具列表和空的会话前缀均为 ABSENT 字段,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop(智能体循环)实例组合一次,由该实例的快照锚定,因此实际上 loop 不会产生前缀 delta。delta 分支(整数组替换,空数组编码「回到无前缀」的转换)存在是为了编解码的完备性。其他 delta payload(`SystemDelta`:公共前缀/后缀行裁剪;`ToolsDelta`:按名称键控的增/删/改)与事件一起定义在 [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)。 ## `SessionEvent<T>`:一条日志条目 @@ -175,7 +175,7 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } ``` -`'append'` 是正常的尾部追加路径。`replace` 遮蔽从 `start` 到 `end`(含两端)的 surface 节点(两者都必须是有效的 surface 节点 seq),并在其位置插入新节点。 +`'append'` 是正常的尾部追加路径。`replace` 遮蔽从 `start` 到 `end`(含两端)的 surface 节点(两者都必须是有效的 surface 节点 seq;`start === end` 时只替换一个节点),并在其位置插入新节点。 ### `SurfaceIntent`:`session.append()` 的参数 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 6d4a5fdaf2..ed154e096c 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write subagent.md: eb9160abaee26969aecdc533fb9fd56fae18b7fa -subagent.zh.md: c8f680217a9f62f245a4de81cbc546deba87bbed +subagent.zh.md: b6a6bfbe61f5d2d3e0eb3bdc3aabed4b8b89317f diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index c8f680217a..b6a6bfbe61 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -64,9 +64,11 @@ interface SubagentStopReasonMap { } ``` +<a id="a-live-run-subagentrun"></a> + ## 活跃 run:`SubagentRun` -`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose(资源释放)该 run 以达到静止状态。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过自身的存在来公布运行时能力。 +`SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose(资源释放)该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过自身的存在来公布运行时能力。 ```ts type-equiv interface SubagentRun { @@ -91,7 +93,7 @@ interface SubagentProvider { } ``` -`start()` 仅在 run 就绪时 fulfill。服务观察其 result、发出 `subagent/start`,并返回同一个 run;rejection 意味着提供方已自行清理,不发出生命周期配对事件。进程内子 agent 可通过 `ctx.agents` 发现,远程子 agent 则不必如此。`subagent/end` 报告最终输出或基础设施故障。两个事件均为仅观察事件,包含监听器异常。 +`start()` 仅在 run 就绪时 fulfill。服务观察其 result、发出 `subagent/start`,并返回同一个 run;rejection 意味着提供方已自行清理,不发出生命周期配对事件。进程内子 agent 可通过 `ctx.agents` 发现,远程子 agent 则不必如此。`subagent/end` 报告最终输出或基础设施故障。两个事件均为仅观察事件;每个监听器异常都会被独立隔离。 ## 进程内后端:深度与种子 diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 1b2db95b67..7f55967dcb 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write tools.md: f8be67054cd81027d4b751329948a784fa4f0ed9 -tools.zh.md: 8b77259b37b0e4c2118cc91af0e6dc3ca27c0479 +tools.zh.md: 27066180bc3a3666e4ef5c789034adc0a82e05b8 diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 8b77259b37..27066180bc 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -46,7 +46,7 @@ interface ToolDefinition extends ToolSchema { ## 类型化 schema DSL -插件作者为每个属性编写带有布尔值 `required: true` 的规格,类型层面的辅助工具将规格映射为 `execute` 的参数类型——零类型断言。该 DSL 是为 `ToolDefinition` 提供类型的*机制*;它有意作为子页面细节,而非核心内容。 +插件作者为每个属性编写带有布尔值 `required: true` 的规格,类型层面的辅助工具将规格映射为 `execute` 的参数类型——零类型断言。该 DSL 是*提供类型推导的机制*,作用于 `ToolDefinition`;它有意作为子页面细节,而非核心内容。 源码:[`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) @@ -193,7 +193,7 @@ type PostToolDecision = ## 结构化输出 schema 子集 -调用方用来向 subagent 要求机器可读结果的词汇(`SubagentStartRequest.outputSchema`,见 [subagent.md](subagent.md#the-start-request)),或工作流 `agent()` 调用使用的词汇。它有意**不是**完整的 JSON Schema:schema 原样传给模型作为强制工具的 `parameters`,产出的值由 `validateStructuredValue` 在客户端校验——因此每个被接受的关键字都必须是校验器实际执行的,`assertSupportedOutputSchema` 会大声拒绝其他任何内容(`OutputSchemaError`,列出所有违规项)。两个遍历器仅推理自有可枚举属性(JSON 不携带其他内容),并拒绝会有损序列化的非纯对象(`Date`、`Map`)。 +调用方用来向 subagent 要求机器可读结果的词汇(`SubagentStartRequest.outputSchema`,见 [subagent.md](subagent.md#the-start-request)),或工作流 `agent()` 调用使用的词汇。它有意不是完整的 JSON Schema:schema 原样传给模型作为强制工具的 `parameters`,产出的值由 `validateStructuredValue` 在客户端校验——因此每个被接受的关键字都必须是校验器实际执行的,`assertSupportedOutputSchema` 会大声拒绝其他任何内容(`OutputSchemaError`,列出所有违规项)。两个遍历器仅推理自有可枚举属性(JSON 不携带其他内容),并拒绝会有损序列化的非纯对象(`Date`、`Map`)。 ```ts type-equiv type StructuredScalar = string | number | boolean | null @@ -230,7 +230,7 @@ type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } 工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI 日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 -- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,无能力的 UI 获得桥接层从 `output` 派生的围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff——例如文件创建。`tool_call_update` 的内容会**替换**调用的内容,因此变更工具即使与调用时的片段重复也要返回此卡片,以防结果文本覆盖 diff)。 +- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,无能力的 UI 获得桥接层从 `output` 派生的围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff——例如文件创建。`tool_call_update` 的内容会替换调用的内容,因此变更工具即使与调用时的片段重复也要返回此卡片,以防结果文本覆盖 diff)。 `ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)为 generic 卡片选择图标。`FileLocation`(`{ path, line? }`)和 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计固定在[渲染意图联合类型 RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) 中;ACP 桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并将文件卡片的标题相对于会话 cwd 做相对化处理。 diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml index 7c248359a9..05d23f25a4 100644 --- a/docs/core-data-structures/web.i18n.yaml +++ b/docs/core-data-structures/web.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write web.md: 74db18835df02ef233f78ad7fbfec5d9b26d58e6 -web.zh.md: 7b94aa457985075c79052aeef66b2e46eac5b49d +web.zh.md: c5b99b72bc9ce1be19ebcad5ffad61fa256e1509 diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md index 7b94aa4579..c5b99b72bc 100644 --- a/docs/core-data-structures/web.zh.md +++ b/docs/core-data-structures/web.zh.md @@ -73,7 +73,7 @@ type WebFetchBody = ## 提供方可用性 -提供方的 `available(): boolean` 是一个廉价的**本地**检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择的输入,而非健康检查系统:`search()`/`fetch()` 读取它以选出可用的提供方,选择失败以结构化的 `WebError` 呈现给调用方路由——其 code 和 message 携带可分支的细节(缺失的 id 或有歧义的候选集)。 +提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择的输入,而非健康检查系统:`search()`/`fetch()` 读取它以选出可用的提供方,选择失败以结构化的 `WebError` 呈现给调用方路由——其 code 和 message 携带可分支的细节(缺失的 id 或有歧义的候选集)。 选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;多个可用提供方且未配置 id 时为 `WEB_PROVIDER_AMBIGUOUS`,而非先注册先赢。 diff --git a/docs/core-data-structures/workflow.i18n.yaml b/docs/core-data-structures/workflow.i18n.yaml index c06899bc74..19a2d0db9e 100644 --- a/docs/core-data-structures/workflow.i18n.yaml +++ b/docs/core-data-structures/workflow.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write workflow.md: 1571723c172fe851e89550e4ed8588ddb14088a0 -workflow.zh.md: 68a5ff9b6ddf43145966e60706a42a758ea260ba +workflow.zh.md: 553d8fa8122b3c9f0cd29840cb2a72d9d5ae6e83 diff --git a/docs/core-data-structures/workflow.zh.md b/docs/core-data-structures/workflow.zh.md index 68a5ff9b6d..553d8fa812 100644 --- a/docs/core-data-structures/workflow.zh.md +++ b/docs/core-data-structures/workflow.zh.md @@ -50,7 +50,7 @@ interface WorkflowResult { ## 活跃运行:`WorkflowRun` -脚本执行期间消费方持有的句柄。消费方 await `result`,可中途 `cancel`,且*必须*在每条路径上 `dispose`。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后卡死。`dispose()` = cancel + 有界 settle + 子 agent 静默;它不会因脚本卡死而挂起。 +脚本执行期间消费方持有的句柄。消费方 await `result`,可中途 `cancel`,且必须在每条路径上 `dispose`。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后卡死。`dispose()` = cancel + 有界 settle + 等待子 agent 停稳;它不会因脚本卡死而挂起。 ```ts type-equiv interface WorkflowRun { diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index f36c38cf0a..2affb91142 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0001-acp-default-export-drops-inject.md: 6a71d8d7ef72e3110a99774b180f3de7115ef622 -0001-acp-default-export-drops-inject.zh.md: 1d97f1140a9595ac7e304b5a1600c3cbc47292f1 +0001-acp-default-export-drops-inject.zh.md: d12feca2eb1a35b68e191945562aabe25e043e37 diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index 1d97f1140a..d12feca2eb 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -1,4 +1,4 @@ -# 事后分析 0001:ACP 服务器在连接时崩溃——`export default` 丢弃了插件的 `inject` +# 事故复盘(postmortem) 0001:ACP 服务器在连接时崩溃——`export default` 丢弃了插件的 `inject` [English](0001-acp-default-export-drops-inject.md) | 中文 @@ -20,7 +20,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 - bridge(RFC 010)落地时附带完整的单元测试套件(codec、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 - 真实 Zed 会话在 `session/new` 上立即失败,报错 `cannot get property "agents" without inject`。 -- 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 的 `reflect.ts` 中对实际 fiber 遍历做了插桩,并运行了真实子进程。trace 显示 throw 发生在 `apply()` 第 179 行、**插件加载时**,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 +- 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 的 `reflect.ts` 中对实际 fiber 遍历做了插桩,并运行了真实子进程。trace 显示 throw 发生在 `apply()` 第 179 行、*插件加载时*,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 - 找到根因 #1:一行多余的 `export default apply`。删除后 `session/new` 修复。 - 删除后暴露了 Bug #2:`session/load` 仍然在 `sessionPersistence` 上抛错——这是一个真正不同的机制(shadow 遍历),通过隔离修复并重新运行真实子进程得到确认。 @@ -57,7 +57,7 @@ unwrapExports(exports: any) { 修复 #1 后,`session/new` 正常工作,但 `session/load` 仍然抛出 `cannot get property "sessionPersistence" without inject`。这个问题*确实*是 Cordis 的 traceable/shadow 机制,值得精确理解。 -`session/load` 调用 `agents.resume(...)`,后者委托给 `AgentLoop.resume()`,其中读取了 `this.ctx.sessionPersistence`。`AgentLoop` 的 `static inject` 故意**不**包含 `sessionPersistence`——注入它会导致非持久化的演示永远挂起,等待一个永远不会加载的后端。该服务由一个独立的兄弟插件/fiber 提供,以机会性方式读取。 +`session/load` 调用 `agents.resume(...)`,后者委托给 `AgentLoop.resume()`,其中读取了 `this.ctx.sessionPersistence`。`AgentLoop` 的 `static inject` 故意不包含 `sessionPersistence`——注入它会导致非持久化的演示永远挂起,等待一个永远不会加载的后端。该服务由一个独立的兄弟插件/fiber 提供,以机会性方式读取。 Cordis 中的服务访问通过上下文代理(`vendor/cordis/src/reflect.ts`)进行。当通过从外部 fiber 获取的 *traceable 代理*调用服务方法时(此处:bridge fiber 调用 `ctx.agents.resume`,注册表返回 `this.factory`——即 `AgentLoop`——重新包装为绑定到调用方的新 traceable 代理),`createShadowMethod`(`vendor/cordis/src/utils.ts`)将 `this` 重新绑定到一个 *shadow* 对象,其 `ctx` 携带 `[symbols.shadow]` 指向 `AgentLoop` 自身的构造上下文。在 `resume` 内部,`this.ctx.sessionPersistence` 的解析从 shadow 的 fiber 开始遍历: @@ -108,6 +108,6 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob ## 经验教训 - 命名空间插件与 default export 在 Cordis Loader 下互斥。选择命名空间形式(`name`/`inject`/`Config`/`apply`),不要添加 `export default`——`unwrapExports` 会丢弃命名空间。 -- 对于插件机会性读取但**未**在 `static inject` 中声明的服务,使用 `ctx.get(name)`,绝不使用 `ctx.<name>`。属性代理通过仅向祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认严格——非活跃后端读取为 `undefined`,而非在 teardown 过程中被交出)。 +- 对于插件机会性读取但未在 `static inject` 中声明的服务,使用 `ctx.get(name)`,绝不使用 `ctx.<name>`。属性代理通过仅向祖先方向的 fiber 遍历解析,经由外部 shadow 时会失败;`ctx.get(name)` 是拓扑无关的查找(且默认严格——非活跃后端读取为 `undefined`,而非在 teardown 过程中被交出)。 - 手动构建插件的测试无法验证插件的加载方式。至少一个测试必须端到端地驱动真实的 Loader/export 路径。当核心操作不调用模型时,该测试无需 API key——因此它属于 CI,而非 key 门控之后。 - 相信 trace,不要相信理论。优雅的 shadow 解释是真实的,但它是*第二个* bug;*第一个*是一行导出错误,在数小时看似合理但实际错误的推理之后,一个 fiber 遍历的 `console.error` 在几分钟内就找到了它。 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index 533c899345..34b22884f8 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0002-js-expression-disabled-filesystem-tools.md: 43e57a6bd1b68f38c47eeda3c3abb8455024b350 -0002-js-expression-disabled-filesystem-tools.zh.md: 35bd54bb6f1551b5927c00184306141417803eaf +0002-js-expression-disabled-filesystem-tools.zh.md: ce091a4f6bd19dd9ccb9100583e92589998a1906 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 35bd54bb6f..ce091a4f6b 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -1,4 +1,4 @@ -# 事后分析 0002:文件系统快照工具被永久禁用 +# 事故复盘(postmortem) 0002:文件系统快照工具被永久禁用 [English](0002-js-expression-disabled-filesystem-tools.md) | 中文 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index 9fd5399786..1fb6e7d64f 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 4dc59e4f5e70f51c4c0baa64fbe34b213f2a7c3d -README.zh.md: e9ea00dacf3fde6f4d04c9a3b6dd5beeb7929660 +README.zh.md: b959566d6e2659957d9b6b121303cc4d0e7c4c4e diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index e9ea00dacf..b959566d6e 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -1,14 +1,14 @@ -# 事后分析 +# 事故复盘(postmortem) [English](README.md) | 中文 -事件复盘:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 +事故复盘:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 -事后分析不是 [RFC](../rfc/README.md)(RFC 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露。 +事故复盘不是 [RFC](../rfc/README.md)(RFC 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露。 -当一个 bug 满足以下条件时,请撰写事后分析:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事后分析所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 +当一个 bug 满足以下条件时,请撰写事故复盘:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事故复盘所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 -每篇事后分析以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、持久的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 +每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、持久的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 | # | 标题 | |---|---| diff --git a/docs/rfc/README.i18n.yaml b/docs/rfc/README.i18n.yaml index 8d007fe169..680bf965c7 100644 --- a/docs/rfc/README.i18n.yaml +++ b/docs/rfc/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 9014579f3a98be907885332a0c815bca5c96855c -README.zh.md: cf258dc9ae1d73bf89d6a82c9bf246152c15e8b4 +README.zh.md: 3ed8b5a03c7f5888a331d0220a7b7c7fb6cd68e2 diff --git a/docs/rfc/README.zh.md b/docs/rfc/README.zh.md index cf258dc9ae..3ed8b5a03c 100644 --- a/docs/rfc/README.zh.md +++ b/docs/rfc/README.zh.md @@ -16,6 +16,8 @@ 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。RFC 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 +<a id="classification"></a> + ## 分类 每份 RFC 属于 `scripts/rfc-index.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。[INDEX.md](INDEX.md) 由路径、标题和文件名日期生成,其新鲜度受门禁保护。新增类别需要同时更新规范集合与本节。见[分类 RFC](implemented/process/2026-06-20-rfc-classification.md) 与[索引生成 RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md)。 @@ -23,7 +25,7 @@ | 类别 | 覆盖范围 | |---|---| | `feature` | 面向用户或模型的新能力。 | -| `bug-fix` | 修正缺陷或弥补事后复盘发现的缺口。 | +| `bug-fix` | 修正缺陷或弥补事故复盘(postmortem)发现的缺口。 | | `simplification` | 在不增加能力的前提下移除代码、行为或对外表面积。 | | `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 | | `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 | @@ -35,7 +37,9 @@ 当一个决策具备以下三个特征时,请写一份 RFC:**持久性**(它的影响超出单个函数或包)、**争议性**(存在一个合理工程师可能选择的真实替代方案)、**意外性**(未来读者否则会问「为什么要这样做」)。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -以下情况**不要**写 RFC:机械性或局部的选择(一个变量名、一次单文件重构);已由门禁或 AGENTS.md 中的约定强制执行并解释的事项;代码中标记为 `TODO(...)` 的临时决策——将其记为 TODO,待稳定后再升级为 RFC。RFC 永远不会被编辑为一个*不同的决策*:用新 RFC 取代旧的,并互相链接。(编辑 `implemented/` RFC 以跟踪其已做出的决策现在*位于*何处——移动的文件、重命名的包——不是不同的决策,这是必需的而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。) +以下情况不要写 RFC:机械性或局部的选择(一个变量名、一次单文件重构);已由门禁或 AGENTS.md 中的约定强制执行并解释的事项;代码中标记为 `TODO(...)` 的临时决策——将其记为 TODO,待稳定后再升级为 RFC。RFC 永远不会被编辑为一个*不同的决策*:用新 RFC 取代旧的,并互相链接。(编辑 `implemented/` RFC 以跟踪其已做出的决策现在*位于*何处——移动的文件、重命名的包——不是不同的决策,这是必需的而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。) + +<a id="the-file-format"></a> ## 文件格式 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 51d98c659e..9840d08659 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -145,6 +145,134 @@ { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" }, { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" } + { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" }, + + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.zh.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/scope.zh.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/scope.zh.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/scope.zh.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.zh.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.zh.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.zh.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.zh.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.zh.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.zh.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.zh.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.zh.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/persistence.zh.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.zh.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, + { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" }, + { "doc": "docs/core-data-structures/user-interaction.zh.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.zh.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.zh.md", "symbol": "AskUserQuestionRequest", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.zh.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.zh.md", "symbol": "AskUserQuestionAnswer", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.zh.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/user-interaction.zh.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" }, + { "doc": "docs/core-data-structures/approval.zh.md", "symbol": "ApprovalRequestId", "source": "packages/ui/user-approval/src/index.ts" }, + { "doc": "docs/core-data-structures/approval.zh.md", "symbol": "ApprovalOutcome", "source": "packages/ui/user-approval/src/index.ts" }, + { "doc": "docs/core-data-structures/approval.zh.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" }, + { "doc": "docs/core-data-structures/approval.zh.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, + { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/sandbox.zh.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, + { "doc": "docs/core-data-structures/sandbox.zh.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, + { "doc": "docs/core-data-structures/sandbox.zh.md", "symbol": "SandboxEnforcement", "source": "packages/sandbox/sandbox/src/index.ts" }, + { "doc": "docs/core-data-structures/sandbox.zh.md", "symbol": "SandboxPolicy", "source": "packages/sandbox/sandbox/src/index.ts" }, + { "doc": "docs/core-data-structures/sandbox.zh.md", "symbol": "ConfinedArgv", "source": "packages/sandbox/sandbox/src/index.ts" }, + { "doc": "docs/core-data-structures/code-runtime.zh.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.zh.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.zh.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.zh.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/code-runtime.zh.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.zh.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillSource", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillResourceBase", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillSummary", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillCandidate", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillDefinition", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillRegistration", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "SkillProvider", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/skills.zh.md", "symbol": "Config", "source": "packages/skill/skill/src/index.ts" }, + { "doc": "docs/core-data-structures/compaction.zh.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.zh.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.zh.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.zh.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.zh.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.zh.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.zh.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/web.zh.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.zh.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.zh.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.zh.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.zh.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.zh.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/workflow.zh.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" }, + { "doc": "docs/core-data-structures/workflow.zh.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, + { "doc": "docs/core-data-structures/workflow.zh.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" }, + { "doc": "docs/core-data-structures/workflow.zh.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" } ] } From 1a39953f69eccde7ff35180eabb70be9aba5060e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:29:39 +0800 Subject: [PATCH 013/113] docs(i18n): address RFC translation review --- ...6-06-11-content-block-vocabulary.i18n.yaml | 4 +- .../2026-06-11-content-block-vocabulary.md | 4 +- .../2026-06-11-content-block-vocabulary.zh.md | 2 +- .../2026-06-11-custom-schema-dsl.i18n.yaml | 4 +- .../2026-06-11-custom-schema-dsl.md | 4 +- .../2026-06-11-custom-schema-dsl.zh.md | 4 +- ...ev-invariants-over-deep-readonly.i18n.yaml | 4 +- ...06-11-dev-invariants-over-deep-readonly.md | 4 +- ...11-dev-invariants-over-deep-readonly.zh.md | 2 +- ...026-06-11-event-sourced-sessions.i18n.yaml | 4 +- .../2026-06-11-event-sourced-sessions.md | 4 +- .../2026-06-11-event-sourced-sessions.zh.md | 8 +-- ...06-11-microkernel-event-taxonomy.i18n.yaml | 4 +- .../2026-06-11-microkernel-event-taxonomy.md | 4 +- ...026-06-11-microkernel-event-taxonomy.zh.md | 2 +- ...026-06-11-runtime-arg-validation.i18n.yaml | 4 +- .../2026-06-11-runtime-arg-validation.md | 4 +- .../2026-06-11-runtime-arg-validation.zh.md | 2 +- ...-06-11-structured-error-taxonomy.i18n.yaml | 4 +- .../2026-06-11-structured-error-taxonomy.md | 4 +- ...2026-06-11-structured-error-taxonomy.zh.md | 2 +- ...-tool-schemas-in-prompt-assembly.i18n.yaml | 4 +- ...6-06-11-tool-schemas-in-prompt-assembly.md | 4 +- ...6-11-tool-schemas-in-prompt-assembly.zh.md | 2 +- .../2026-06-13-capability-seams.i18n.yaml | 4 +- .../2026-06-13-capability-seams.md | 4 +- .../2026-06-13-capability-seams.zh.md | 8 +-- .../2026-06-13-twin-llm-adapters.i18n.yaml | 4 +- .../2026-06-13-twin-llm-adapters.md | 4 +- .../2026-06-13-twin-llm-adapters.zh.md | 2 +- .../2026-06-14-session-persistence.i18n.yaml | 4 +- .../2026-06-14-session-persistence.md | 4 +- .../2026-06-14-session-persistence.zh.md | 4 +- ...6-06-15-turn-enclosure-invariant.i18n.yaml | 4 +- .../2026-06-15-turn-enclosure-invariant.md | 4 +- .../2026-06-15-turn-enclosure-invariant.zh.md | 4 +- ...06-17-filesystem-capability-seam.i18n.yaml | 4 +- .../2026-06-17-filesystem-capability-seam.md | 4 +- ...026-06-17-filesystem-capability-seam.zh.md | 18 ++--- ...nt-lifecycle-and-ownership-seams.i18n.yaml | 4 +- ...-18-agent-lifecycle-and-ownership-seams.md | 4 +- ...-agent-lifecycle-and-ownership-seams.zh.md | 6 +- .../2026-06-18-session-surface.i18n.yaml | 4 +- .../2026-06-18-session-surface.md | 4 +- .../2026-06-18-session-surface.zh.md | 6 +- ...ed-persistence-write-coordinator.i18n.yaml | 4 +- ...18-shared-persistence-write-coordinator.md | 4 +- ...shared-persistence-write-coordinator.zh.md | 18 ++--- .../2026-06-20-branded-ids.i18n.yaml | 4 +- .../architecture/2026-06-20-branded-ids.md | 4 +- .../architecture/2026-06-20-branded-ids.zh.md | 14 ++-- ...-20-extract-example-app-packages.i18n.yaml | 4 +- ...2026-06-20-extract-example-app-packages.md | 4 +- ...6-06-20-extract-example-app-packages.zh.md | 6 +- .../2026-06-20-package-hierarchy.i18n.yaml | 4 +- .../2026-06-20-package-hierarchy.md | 4 +- .../2026-06-20-package-hierarchy.zh.md | 6 +- ...andatory-app-attribution-headers.i18n.yaml | 4 +- ...06-21-mandatory-app-attribution-headers.md | 4 +- ...21-mandatory-app-attribution-headers.zh.md | 10 +-- .../2026-06-24-web-capability-seam.i18n.yaml | 4 +- .../2026-06-24-web-capability-seam.md | 4 +- .../2026-06-24-web-capability-seam.zh.md | 2 +- ...06-26-file-context-as-event-gate.i18n.yaml | 4 +- .../2026-06-26-file-context-as-event-gate.md | 4 +- ...026-06-26-file-context-as-event-gate.zh.md | 6 +- ...stdin-env-trusted-plugin-surface.i18n.yaml | 4 +- ...0-bash-stdin-env-trusted-plugin-surface.md | 4 +- ...ash-stdin-env-trusted-plugin-surface.zh.md | 2 +- ...026-06-30-event-domain-semantics.i18n.yaml | 4 +- .../2026-06-30-event-domain-semantics.md | 4 +- .../2026-06-30-event-domain-semantics.zh.md | 8 +-- .../2026-07-02-fs-per-session-cwd.i18n.yaml | 4 +- .../2026-07-02-fs-per-session-cwd.md | 4 +- .../2026-07-02-fs-per-session-cwd.zh.md | 8 +-- ...2-result-time-applied-hunk-diffs.i18n.yaml | 4 +- ...26-07-02-result-time-applied-hunk-diffs.md | 4 +- ...07-02-result-time-applied-hunk-diffs.zh.md | 10 +-- ...6-07-02-tool-render-intent-union.i18n.yaml | 4 +- .../2026-07-02-tool-render-intent-union.md | 4 +- .../2026-07-02-tool-render-intent-union.zh.md | 2 +- ...ilesystem-directory-listing-seam.i18n.yaml | 4 +- ...07-03-filesystem-directory-listing-seam.md | 4 +- ...03-filesystem-directory-listing-seam.zh.md | 6 +- ...bles-and-tool-guidance-ownership.i18n.yaml | 4 +- ...t-variables-and-tool-guidance-ownership.md | 4 +- ...ariables-and-tool-guidance-ownership.zh.md | 12 ++-- ...6-07-05-reconstructable-requests.i18n.yaml | 4 +- .../2026-07-05-reconstructable-requests.md | 4 +- .../2026-07-05-reconstructable-requests.zh.md | 4 +- ...bagent-provider-lifecycle-events.i18n.yaml | 4 +- ...7-05-subagent-provider-lifecycle-events.md | 4 +- ...5-subagent-provider-lifecycle-events.zh.md | 16 ++--- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-06-timeout-deadline-library.md | 4 +- .../2026-07-06-timeout-deadline-library.zh.md | 2 +- ...6-07-07-tool-call-timeout-policy.i18n.yaml | 4 +- .../2026-07-07-tool-call-timeout-policy.md | 4 +- .../2026-07-07-tool-call-timeout-policy.zh.md | 10 +-- .../2026-07-08-agent-scope-contexts.i18n.yaml | 4 +- .../2026-07-08-agent-scope-contexts.md | 4 +- .../2026-07-08-agent-scope-contexts.zh.md | 2 +- ...07-12-agent-scope-runtime-design.i18n.yaml | 4 +- .../2026-07-12-agent-scope-runtime-design.md | 4 +- ...026-07-12-agent-scope-runtime-design.zh.md | 6 +- ...-06-14-acp-agent-client-protocol.i18n.yaml | 4 +- .../2026-06-14-acp-agent-client-protocol.md | 4 +- ...2026-06-14-acp-agent-client-protocol.zh.md | 6 +- .../2026-06-14-acp-multi-session.i18n.yaml | 4 +- .../feature/2026-06-14-acp-multi-session.md | 4 +- .../2026-06-14-acp-multi-session.zh.md | 6 +- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +- .../feature/2026-06-15-code-mode.md | 4 +- .../feature/2026-06-15-code-mode.zh.md | 8 +-- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 4 +- .../2026-06-17-filesystem-tool-schemas.md | 4 +- .../2026-06-17-filesystem-tool-schemas.zh.md | 16 ++--- ...-acp-terminal-and-tool-rendering.i18n.yaml | 4 +- ...6-06-18-acp-terminal-and-tool-rendering.md | 4 +- ...6-18-acp-terminal-and-tool-rendering.zh.md | 8 +-- ...06-18-compaction-capability-seam.i18n.yaml | 4 +- .../2026-06-18-compaction-capability-seam.md | 4 +- ...026-06-18-compaction-capability-seam.zh.md | 4 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 4 +- .../2026-06-21-subagent-capability-seam.zh.md | 8 +-- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.md | 4 +- .../2026-06-22-acp-subagent-backend.zh.md | 12 ++-- .../2026-06-25-ask-user-question.i18n.yaml | 4 +- .../feature/2026-06-25-ask-user-question.md | 4 +- .../2026-06-25-ask-user-question.zh.md | 6 +- .../2026-06-29-todo-write-tool.i18n.yaml | 4 +- .../feature/2026-06-29-todo-write-tool.md | 4 +- .../feature/2026-06-29-todo-write-tool.zh.md | 8 +-- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.md | 4 +- .../feature/2026-06-30-hook-bridges.zh.md | 4 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 4 +- .../2026-06-30-hook-protocol-lib.zh.md | 8 +-- .../2026-06-30-interception-seams.i18n.yaml | 4 +- .../feature/2026-06-30-interception-seams.md | 4 +- .../2026-06-30-interception-seams.zh.md | 6 +- ...026-06-30-session-store-fork-api.i18n.yaml | 4 +- .../2026-06-30-session-store-fork-api.md | 4 +- .../2026-06-30-session-store-fork-api.zh.md | 6 +- ...26-06-30-subagent-observe-enrich.i18n.yaml | 4 +- .../2026-06-30-subagent-observe-enrich.md | 4 +- .../2026-06-30-subagent-observe-enrich.zh.md | 2 +- .../2026-07-05-dynamic-workflows.i18n.yaml | 4 +- .../feature/2026-07-05-dynamic-workflows.md | 4 +- .../2026-07-05-dynamic-workflows.zh.md | 12 ++-- .../feature/2026-07-05-skill-system.i18n.yaml | 4 +- .../feature/2026-07-05-skill-system.md | 4 +- .../feature/2026-07-05-skill-system.zh.md | 6 +- .../2026-07-06-approval-seam.i18n.yaml | 4 +- .../feature/2026-07-06-approval-seam.md | 4 +- .../feature/2026-07-06-approval-seam.zh.md | 6 +- .../2026-07-06-explicit-tool-order.i18n.yaml | 4 +- .../feature/2026-07-06-explicit-tool-order.md | 4 +- .../2026-07-06-explicit-tool-order.zh.md | 6 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 4 +- .../feature/2026-07-06-sandbox.zh.md | 72 +++++++++---------- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../feature/2026-07-07-mcp-client-plugin.md | 4 +- .../2026-07-07-mcp-client-plugin.zh.md | 4 +- .../2026-07-07-session-prefix.i18n.yaml | 4 +- .../feature/2026-07-07-session-prefix.md | 4 +- .../feature/2026-07-07-session-prefix.zh.md | 10 +-- .../2026-07-08-repeat-tool-guard.i18n.yaml | 4 +- .../feature/2026-07-08-repeat-tool-guard.md | 4 +- .../2026-07-08-repeat-tool-guard.zh.md | 2 +- ...-self-referential-cordis-toolset.i18n.yaml | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 4 +- ...7-08-self-referential-cordis-toolset.zh.md | 6 +- ...2026-07-10-session-query-service.i18n.yaml | 4 +- .../2026-07-10-session-query-service.md | 4 +- .../2026-07-10-session-query-service.zh.md | 6 +- ...nt-persona-tool-filter-and-depth.i18n.yaml | 4 +- ...-subagent-persona-tool-filter-and-depth.md | 4 +- ...bagent-persona-tool-filter-and-depth.zh.md | 6 +- .../2026-06-11-doc-sync-enforcement.i18n.yaml | 4 +- .../2026-06-11-doc-sync-enforcement.md | 4 +- .../2026-06-11-doc-sync-enforcement.zh.md | 8 +-- .../2026-06-11-quality-gates.i18n.yaml | 4 +- .../process/2026-06-11-quality-gates.md | 4 +- .../process/2026-06-11-quality-gates.zh.md | 8 +-- .../2026-06-11-tsdown-over-dumble.i18n.yaml | 4 +- .../process/2026-06-11-tsdown-over-dumble.md | 4 +- .../2026-06-11-tsdown-over-dumble.zh.md | 6 +- ...26-06-11-vendor-cordis-as-source.i18n.yaml | 4 +- .../2026-06-11-vendor-cordis-as-source.md | 4 +- .../2026-06-11-vendor-cordis-as-source.zh.md | 6 +- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 4 +- .../process/2026-06-16-pnpm-over-yarn.md | 4 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 6 +- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.md | 4 +- .../process/2026-06-17-ts-build-config.zh.md | 8 +-- ...6-06-18-markdown-cross-link-lint.i18n.yaml | 4 +- .../2026-06-18-markdown-cross-link-lint.md | 4 +- .../2026-06-18-markdown-cross-link-lint.zh.md | 8 +-- ...-20-core-data-structures-catalog.i18n.yaml | 4 +- ...2026-06-20-core-data-structures-catalog.md | 4 +- ...6-06-20-core-data-structures-catalog.zh.md | 6 +- ...6-06-20-generated-cordis-catalog.i18n.yaml | 4 +- .../2026-06-20-generated-cordis-catalog.md | 4 +- .../2026-06-20-generated-cordis-catalog.zh.md | 6 +- .../2026-06-20-rfc-classification.i18n.yaml | 4 +- .../process/2026-06-20-rfc-classification.md | 4 +- .../2026-06-20-rfc-classification.zh.md | 10 +-- .../2026-07-02-tool-schema-catalog.i18n.yaml | 4 +- .../process/2026-07-02-tool-schema-catalog.md | 4 +- .../2026-07-02-tool-schema-catalog.zh.md | 10 +-- ...-07-03-documentation-graph-atlas.i18n.yaml | 4 +- .../2026-07-03-documentation-graph-atlas.md | 4 +- ...2026-07-03-documentation-graph-atlas.zh.md | 6 +- ...4-cordis-jsdoc-completeness-gate.i18n.yaml | 4 +- ...26-07-04-cordis-jsdoc-completeness-gate.md | 4 +- ...07-04-cordis-jsdoc-completeness-gate.zh.md | 8 +-- ...2026-07-04-doc-tiers-and-budgets.i18n.yaml | 4 +- .../2026-07-04-doc-tiers-and-budgets.md | 4 +- .../2026-07-04-doc-tiers-and-budgets.zh.md | 6 +- ...-07-04-generate-rfc-index-tables.i18n.yaml | 4 +- .../2026-07-04-generate-rfc-index-tables.md | 4 +- ...2026-07-04-generate-rfc-index-tables.zh.md | 8 +-- ...26-07-04-persistence-log-catalog.i18n.yaml | 4 +- .../2026-07-04-persistence-log-catalog.md | 4 +- .../2026-07-04-persistence-log-catalog.zh.md | 2 +- .../2026-07-05-uniform-rfc-format.i18n.yaml | 4 +- .../process/2026-07-05-uniform-rfc-format.md | 4 +- .../2026-07-05-uniform-rfc-format.zh.md | 8 +-- ...-07-06-export-surface-jsdoc-gate.i18n.yaml | 4 +- .../2026-07-06-export-surface-jsdoc-gate.md | 4 +- ...2026-07-06-export-surface-jsdoc-gate.zh.md | 10 +-- ...6-07-06-generated-config-catalog.i18n.yaml | 4 +- .../2026-07-06-generated-config-catalog.md | 4 +- .../2026-07-06-generated-config-catalog.zh.md | 2 +- .../2026-07-06-node-engine-floor.i18n.yaml | 4 +- .../process/2026-07-06-node-engine-floor.md | 4 +- .../2026-07-06-node-engine-floor.zh.md | 6 +- ...6-07-06-parallel-github-ci-gates.i18n.yaml | 4 +- .../2026-07-06-parallel-github-ci-gates.md | 4 +- .../2026-07-06-parallel-github-ci-gates.zh.md | 6 +- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 4 +- .../2026-07-06-parallel-pre-push-gates.md | 4 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 6 +- ...10-readme-known-limitations-gate.i18n.yaml | 4 +- ...026-07-10-readme-known-limitations-gate.md | 4 +- ...-07-10-readme-known-limitations-gate.zh.md | 6 +- ...ackage-model-experience-contract.i18n.yaml | 4 +- ...07-12-package-model-experience-contract.md | 4 +- ...12-package-model-experience-contract.zh.md | 8 +-- ...-19-drop-mutable-session-summary.i18n.yaml | 4 +- ...2026-06-19-drop-mutable-session-summary.md | 4 +- ...6-06-19-drop-mutable-session-summary.zh.md | 8 +-- ...llapse-trace-only-session-events.i18n.yaml | 4 +- ...6-20-collapse-trace-only-session-events.md | 4 +- ...0-collapse-trace-only-session-events.zh.md | 2 +- ...onsumed-llm-adapter-change-event.i18n.yaml | 4 +- ...rop-unconsumed-llm-adapter-change-event.md | 4 +- ...-unconsumed-llm-adapter-change-event.zh.md | 2 +- ...nconsumed-llm-assembled-surfaces.i18n.yaml | 4 +- ...-drop-unconsumed-llm-assembled-surfaces.md | 4 +- ...op-unconsumed-llm-assembled-surfaces.zh.md | 2 +- ...26-06-20-prune-dead-seam-methods.i18n.yaml | 4 +- .../2026-06-20-prune-dead-seam-methods.md | 4 +- .../2026-06-20-prune-dead-seam-methods.zh.md | 6 +- ...-06-20-public-agent-stop-surface.i18n.yaml | 4 +- .../2026-06-20-public-agent-stop-surface.md | 4 +- ...2026-06-20-public-agent-stop-surface.zh.md | 2 +- ...ove-agent-boundary-mirror-events.i18n.yaml | 4 +- ...-20-remove-agent-boundary-mirror-events.md | 4 +- ...-remove-agent-boundary-mirror-events.zh.md | 6 +- .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 4 +- .../2026-06-26-fsspec-style-fs-seam.md | 4 +- .../2026-06-26-fsspec-style-fs-seam.zh.md | 20 +++--- ...07-02-remove-stream-chunk-mirror.i18n.yaml | 4 +- .../2026-07-02-remove-stream-chunk-mirror.md | 4 +- ...026-07-02-remove-stream-chunk-mirror.zh.md | 6 +- ...6-07-04-drop-image-content-block.i18n.yaml | 4 +- .../2026-07-04-drop-image-content-block.md | 4 +- .../2026-07-04-drop-image-content-block.zh.md | 8 +-- ...6-07-04-drop-inert-request-knobs.i18n.yaml | 4 +- .../2026-07-04-drop-inert-request-knobs.md | 4 +- .../2026-07-04-drop-inert-request-knobs.zh.md | 12 ++-- ...consumed-web-observation-surface.i18n.yaml | 4 +- ...drop-unconsumed-web-observation-surface.md | 4 +- ...p-unconsumed-web-observation-surface.zh.md | 4 +- .../2026-07-04-fold-stdio-ui-helper.i18n.yaml | 4 +- .../2026-07-04-fold-stdio-ui-helper.md | 4 +- .../2026-07-04-fold-stdio-ui-helper.zh.md | 6 +- ...producerless-vocabulary-variants.i18n.yaml | 4 +- ...-prune-producerless-vocabulary-variants.md | 4 +- ...une-producerless-vocabulary-variants.zh.md | 6 +- ...7-04-prune-write-only-fs-surface.i18n.yaml | 4 +- .../2026-07-04-prune-write-only-fs-surface.md | 4 +- ...26-07-04-prune-write-only-fs-surface.zh.md | 6 +- ...-04-remove-agent-steering-mirror.i18n.yaml | 4 +- ...2026-07-04-remove-agent-steering-mirror.md | 4 +- ...6-07-04-remove-agent-steering-mirror.zh.md | 8 +-- ...26-07-04-share-app-bin-boot-glue.i18n.yaml | 4 +- .../2026-07-04-share-app-bin-boot-glue.md | 4 +- .../2026-07-04-share-app-bin-boot-glue.zh.md | 6 +- ...4-tighten-hook-protocol-contract.i18n.yaml | 4 +- ...26-07-04-tighten-hook-protocol-contract.md | 4 +- ...07-04-tighten-hook-protocol-contract.zh.md | 6 +- ...m-acp-bridge-unreachable-surface.i18n.yaml | 4 +- ...-04-trim-acp-bridge-unreachable-surface.md | 4 +- ...-trim-acp-bridge-unreachable-surface.zh.md | 6 +- ...unconsumed-skill-provider-events.i18n.yaml | 4 +- ...2-drop-unconsumed-skill-provider-events.md | 4 +- ...rop-unconsumed-skill-provider-events.zh.md | 6 +- ...-12-prune-unused-web-seam-fields.i18n.yaml | 4 +- ...2026-07-12-prune-unused-web-seam-fields.md | 4 +- ...6-07-12-prune-unused-web-seam-fields.zh.md | 6 +- ...026-06-11-property-based-testing.i18n.yaml | 4 +- .../2026-06-11-property-based-testing.md | 4 +- .../2026-06-11-property-based-testing.zh.md | 4 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +- .../testing/2026-06-19-acp-snapshot-tests.md | 4 +- .../2026-06-19-acp-snapshot-tests.zh.md | 2 +- .../2026-06-19-real-api-e2e-ci.i18n.yaml | 4 +- .../testing/2026-06-19-real-api-e2e-ci.md | 4 +- .../testing/2026-06-19-real-api-e2e-ci.zh.md | 6 +- ...e-redundant-snapshot-log-goldens.i18n.yaml | 4 +- ...0-remove-redundant-snapshot-log-goldens.md | 4 +- ...emove-redundant-snapshot-log-goldens.zh.md | 2 +- ...-fork-child-replay-seed-boundary.i18n.yaml | 4 +- ...6-06-22-fork-child-replay-seed-boundary.md | 4 +- ...6-22-fork-child-replay-seed-boundary.zh.md | 6 +- ...26-06-22-fork-snapshot-scenarios.i18n.yaml | 4 +- .../2026-06-22-fork-snapshot-scenarios.md | 4 +- .../2026-06-22-fork-snapshot-scenarios.zh.md | 8 +-- ...6-06-22-subagent-snapshot-replay.i18n.yaml | 4 +- .../2026-06-22-subagent-snapshot-replay.md | 4 +- .../2026-06-22-subagent-snapshot-replay.zh.md | 10 +-- .../2026-07-04-hook-snapshot-matrix.i18n.yaml | 4 +- .../2026-07-04-hook-snapshot-matrix.md | 4 +- .../2026-07-04-hook-snapshot-matrix.zh.md | 14 ++-- ...-single-source-acp-replay-config.i18n.yaml | 4 +- ...6-07-04-single-source-acp-replay-config.md | 4 +- ...7-04-single-source-acp-replay-config.zh.md | 8 +-- ...t-header-content-in-one-scenario.i18n.yaml | 4 +- ...-request-header-content-in-one-scenario.md | 4 +- ...quest-header-content-in-one-scenario.zh.md | 2 +- ...7-08-shared-acp-snapshot-package.i18n.yaml | 4 +- .../2026-07-08-shared-acp-snapshot-package.md | 4 +- ...26-07-08-shared-acp-snapshot-package.zh.md | 6 +- .../2026-06-16-typed-event-schemas.i18n.yaml | 4 +- .../2026-06-16-typed-event-schemas.md | 4 +- .../2026-06-16-typed-event-schemas.zh.md | 8 +-- ...eneric-long-running-tool-runtime.i18n.yaml | 4 +- ...06-20-generic-long-running-tool-runtime.md | 4 +- ...20-generic-long-running-tool-runtime.zh.md | 2 +- ...026-06-30-pre-tool-input-rewrite.i18n.yaml | 4 +- .../2026-06-30-pre-tool-input-rewrite.md | 4 +- .../2026-06-30-pre-tool-input-rewrite.zh.md | 6 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 4 +- ...ude-code-and-codex-subagent-backends.zh.md | 16 ++--- ...-07-08-interactive-side-sessions.i18n.yaml | 4 +- .../2026-07-08-interactive-side-sessions.md | 4 +- ...2026-07-08-interactive-side-sessions.zh.md | 6 +- ...10-sqlite-session-query-provider.i18n.yaml | 4 +- ...026-07-10-sqlite-session-query-provider.md | 4 +- ...-07-10-sqlite-session-query-provider.zh.md | 6 +- ...flow-progress-through-tool-calls.i18n.yaml | 4 +- ...am-workflow-progress-through-tool-calls.md | 4 +- ...workflow-progress-through-tool-calls.zh.md | 6 +- ...2026-06-11-api-extractor-reports.i18n.yaml | 4 +- .../2026-06-11-api-extractor-reports.md | 4 +- .../2026-06-11-api-extractor-reports.zh.md | 6 +- ...-06-11-architectural-conformance.i18n.yaml | 4 +- .../2026-06-11-architectural-conformance.md | 4 +- ...2026-06-11-architectural-conformance.zh.md | 6 +- ...11-supply-chain-and-vendor-drift.i18n.yaml | 4 +- ...026-06-11-supply-chain-and-vendor-drift.md | 4 +- ...-06-11-supply-chain-and-vendor-drift.zh.md | 8 +-- ...06-20-discover-package-inventory.i18n.yaml | 4 +- .../2026-06-20-discover-package-inventory.md | 4 +- ...026-06-20-discover-package-inventory.zh.md | 4 +- ...06-20-unify-agent-and-session-id.i18n.yaml | 4 +- .../2026-06-20-unify-agent-and-session-id.md | 4 +- ...026-06-20-unify-agent-and-session-id.zh.md | 6 +- ...04-prune-dead-core-spine-surface.i18n.yaml | 4 +- ...026-07-04-prune-dead-core-spine-surface.md | 4 +- ...-07-04-prune-dead-core-spine-surface.zh.md | 2 +- ...plify-session-log-representation.i18n.yaml | 4 +- ...-12-simplify-session-log-representation.md | 4 +- ...-simplify-session-log-representation.zh.md | 6 +- ...deterministic-and-stress-testing.i18n.yaml | 4 +- ...-06-11-deterministic-and-stress-testing.md | 4 +- ...-11-deterministic-and-stress-testing.zh.md | 8 +-- .../2026-06-11-mutation-testing.i18n.yaml | 4 +- .../testing/2026-06-11-mutation-testing.md | 4 +- .../testing/2026-06-11-mutation-testing.zh.md | 6 +- ...-06-11-immutable-public-surfaces.i18n.yaml | 4 +- .../2026-06-11-immutable-public-surfaces.md | 4 +- ...2026-06-11-immutable-public-surfaces.zh.md | 8 +-- ...-06-20-providerless-example-base.i18n.yaml | 4 +- .../2026-06-20-providerless-example-base.md | 4 +- ...2026-06-20-providerless-example-base.zh.md | 6 +- ...ssembled-assistant-messages-only.i18n.yaml | 4 +- ...06-20-assembled-assistant-messages-only.md | 4 +- ...20-assembled-assistant-messages-only.zh.md | 6 +- ...2026-06-20-drop-acp-session-load.i18n.yaml | 4 +- .../2026-06-20-drop-acp-session-load.md | 4 +- .../2026-06-20-drop-acp-session-load.zh.md | 6 +- ...026-06-20-drop-acp-terminal-meta.i18n.yaml | 4 +- .../2026-06-20-drop-acp-terminal-meta.md | 4 +- .../2026-06-20-drop-acp-terminal-meta.zh.md | 6 +- ...-20-drop-bash-output-spill-files.i18n.yaml | 4 +- ...2026-06-20-drop-bash-output-spill-files.md | 4 +- ...6-06-20-drop-bash-output-spill-files.zh.md | 6 +- ...-20-drop-durable-step-boundaries.i18n.yaml | 4 +- ...2026-06-20-drop-durable-step-boundaries.md | 4 +- ...6-06-20-drop-durable-step-boundaries.zh.md | 2 +- ...6-20-drop-unused-session-lineage.i18n.yaml | 4 +- .../2026-06-20-drop-unused-session-lineage.md | 4 +- ...26-06-20-drop-unused-session-lineage.zh.md | 6 +- ...ld-session-persistence-interface.i18n.yaml | 4 +- ...6-20-fold-session-persistence-interface.md | 4 +- ...0-fold-session-persistence-interface.zh.md | 2 +- ...026-06-20-generic-tool-rendering.i18n.yaml | 4 +- .../2026-06-20-generic-tool-rendering.md | 4 +- .../2026-06-20-generic-tool-rendering.zh.md | 6 +- ...6-06-20-retire-mid-turn-steering.i18n.yaml | 4 +- .../2026-06-20-retire-mid-turn-steering.md | 4 +- .../2026-06-20-retire-mid-turn-steering.zh.md | 6 +- ...-06-20-single-session-acp-bridge.i18n.yaml | 4 +- .../2026-06-20-single-session-acp-bridge.md | 4 +- ...2026-06-20-single-session-acp-bridge.zh.md | 6 +- ...06-20-truncate-interrupted-turns.i18n.yaml | 4 +- .../2026-06-20-truncate-interrupted-turns.md | 4 +- ...026-06-20-truncate-interrupted-turns.zh.md | 2 +- ...nimplemented-subagent-vocabulary.i18n.yaml | 4 +- ...prune-unimplemented-subagent-vocabulary.md | 4 +- ...ne-unimplemented-subagent-vocabulary.zh.md | 6 +- ...apse-workflow-to-foreground-core.i18n.yaml | 4 +- ...12-collapse-workflow-to-foreground-core.md | 4 +- ...collapse-workflow-to-foreground-core.zh.md | 6 +- ...ne-unused-skill-registry-surface.i18n.yaml | 4 +- ...-12-prune-unused-skill-registry-surface.md | 4 +- ...-prune-unused-skill-registry-surface.zh.md | 6 +- 447 files changed, 1106 insertions(+), 1106 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 7ddbc75586..fb029f04fe 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-content-block-vocabulary.md: 9414bda624fa6e5fc7e9b11b7a738d32b269af6b -2026-06-11-content-block-vocabulary.zh.md: 764791cbef65c2031b8337af4f4cb6835e9d312a +2026-06-11-content-block-vocabulary.md: 6a90813ec90c6522a88a09d3536ce0cef8250238 +2026-06-11-content-block-vocabulary.zh.md: c9c65ae7e556cdf4eced0d45051862ff87fcd0fe diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 9414bda624..6a90813ec9 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -1,9 +1,9 @@ # RFC: Provider-neutral content-block vocabulary owned by dsh-llm -English | [中文](2026-06-11-content-block-vocabulary.zh.md) - Status: implemented +English | [中文](2026-06-11-content-block-vocabulary.zh.md) + ## Problem The harness needs one internal language for messages that the loop, session log, and all plugins speak. diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index 764791cbef..c9c65ae7e5 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -1,4 +1,4 @@ -# RFC:由 dsh-llm 拥有的提供方无关内容块词汇 +# RFC: 由 dsh-llm 拥有的提供方无关内容块词汇 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml index 6e7b3a2125..c08b4133de 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-custom-schema-dsl.md: 4c4d572b15d5e474e00e99fc5e7dd89240251c63 -2026-06-11-custom-schema-dsl.zh.md: eca0428d46ba3b3a4beac0cf9e8f02a9fa198388 +2026-06-11-custom-schema-dsl.md: 34c018b779d45c5eadb7337cb060c43b6f8d09b1 +2026-06-11-custom-schema-dsl.zh.md: 674a6b1a0b67617ffb4ab919899aa108c646a489 diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md index 4c4d572b15..34c018b779 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md @@ -1,9 +1,9 @@ # RFC: Custom typed tool-schema DSL instead of schemastery -English | [中文](2026-06-11-custom-schema-dsl.zh.md) - Status: implemented +English | [中文](2026-06-11-custom-schema-dsl.zh.md) + ## Problem Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array. diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md index eca0428d46..674a6b1a0b 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md @@ -1,4 +1,4 @@ -# RFC:使用自定义类型化 tool-schema DSL 替代 schemastery +# RFC: 使用自定义类型化 tool-schema DSL 替代 schemastery Status: implemented @@ -14,7 +14,7 @@ Status: implemented ## 曾考虑的替代方案 -**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema **生成**,因此会增加间接层却无法干净地产出协议格式(wire format)。 +**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。 ## 后果 diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 0758761505..2df4576e59 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-dev-invariants-over-deep-readonly.md: dc89b5b66d02bde2f4fe2794e04b76ecdeac62ae -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 2c3c9e221b42f4b45216d09e825a41b1be3bcee9 +2026-06-11-dev-invariants-over-deep-readonly.md: 01a9e45fac77d2513924e78566a44a6059dd9d28 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 8f8db9f5bb095b8fc9c600b741c16d630a6c8167 diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index dc89b5b66d..01a9e45fac 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -1,9 +1,9 @@ # RFC: Source-owned session immutability and dev-mode invariants -English | [中文](2026-06-11-dev-invariants-over-deep-readonly.zh.md) - Status: implemented +English | [中文](2026-06-11-dev-invariants-over-deep-readonly.zh.md) + ## Problem The session log needs two different protections: immutable ownership of each stored fact, and checks for relationships among facts across time and service seams. Conflating them in an optional development plugin would leave production history vulnerable; trying to express both through TypeScript readonly types would not create a runtime boundary or describe relational rules. diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 2c3c9e221b..8f8db9f5bb 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -1,4 +1,4 @@ -# RFC:源端拥有的会话不可变性与开发模式不变式 +# RFC: 源端拥有的会话不可变性与开发模式不变式 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index e7645f5f8d..747bbf3ab0 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-event-sourced-sessions.md: 04ff974826ffbc9052c7eb9f5794bc16557241f9 -2026-06-11-event-sourced-sessions.zh.md: 12d6aedcc32b4d93f0dbe010854bcd3a0721703f +2026-06-11-event-sourced-sessions.md: c1460b84c5928a53537a5f6a51f41c4d5b101a12 +2026-06-11-event-sourced-sessions.zh.md: 938404524cd23a5a5b3d6d0b7fd5020df3d35a51 diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md index 04ff974826..c1460b84c5 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -1,9 +1,9 @@ # RFC: Event-sourced sessions with derived message history -English | [中文](2026-06-11-event-sourced-sessions.zh.md) - Status: implemented +English | [中文](2026-06-11-event-sourced-sessions.zh.md) + ## Problem The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统,session完全可回放). diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index 12d6aedcc3..938404524c 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -1,9 +1,9 @@ -# RFC:事件溯源的会话与派生消息历史 - -[English](2026-06-11-event-sourced-sessions.md) | 中文 +# RFC: 事件溯源的会话与派生消息历史 Status: implemented +[English](2026-06-11-event-sourced-sessions.md) | 中文 + ## 问题 MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严格的基于事件的 trace、logging 系统,session 完全可回放)。 @@ -14,7 +14,7 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 追加操作是同步的(热路径从不阻塞于 I/O);`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。 -顺序契约:agent loop(智能体循环)先追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。 +顺序契约:agent loop(智能体循环)*先*追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall(瀑布式事件)在 `assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。 ## 曾考虑的替代方案 diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml index 8634a86f36..107aab3c75 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-microkernel-event-taxonomy.md: c66968257a5a6304f187ccb5b9a162aa143e608d -2026-06-11-microkernel-event-taxonomy.zh.md: 7becf5872aee16fe21b4acbbc61920ed91c40f44 +2026-06-11-microkernel-event-taxonomy.md: 47e363f949b506756d9b20ee7dc53ca81e3f5e4d +2026-06-11-microkernel-event-taxonomy.zh.md: 3b0aa54108bf8737e6fe341c7b0797a554d328ec diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index c66968257a..47e363f949 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -1,9 +1,9 @@ # RFC: Microkernel — extension via Cordis event taxonomy, one concrete loop -English | [中文](2026-06-11-microkernel-event-taxonomy.zh.md) - Status: implemented +English | [中文](2026-06-11-microkernel-event-taxonomy.zh.md) + ## Problem The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core. diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md index 7becf5872a..3b0aa54108 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -1,4 +1,4 @@ -# RFC:微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环 +# RFC: 微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index 9ae6b5d602..0fa22e5594 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-runtime-arg-validation.md: 6da117643166d304bee1d368a314cc1602cac828 -2026-06-11-runtime-arg-validation.zh.md: 5f41ff99a2d56f39ff8d61191d92dc782f163c82 +2026-06-11-runtime-arg-validation.md: 82d01263225ce719e80631d1890f0a6cc328119d +2026-06-11-runtime-arg-validation.zh.md: 4bd6720f7ec87fcef028ba94f445a5210f1b0d7a diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md index 6da1176431..82d0126322 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md @@ -1,9 +1,9 @@ # RFC: Runtime arg validation at the model boundary -English | [中文](2026-06-11-runtime-arg-validation.zh.md) - Status: implemented +English | [中文](2026-06-11-runtime-arg-validation.zh.md) + ## Problem `defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, an enum value outside the set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape (a generic stack trace the model can't act on) or, worse, silently misbehaved. Meanwhile the converter already encodes the exact structure a validator would need to walk. diff --git a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index 5f41ff99a2..4bd6720f7e 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -1,4 +1,4 @@ -# RFC:模型边界处的运行时参数校验 +# RFC: 模型边界处的运行时参数校验 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml index aa6cdb26ff..4a381ba271 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-structured-error-taxonomy.md: 2baf88a1f942215e79561e565f455276c80178c4 -2026-06-11-structured-error-taxonomy.zh.md: 90b0fdd7f6c8b4f0565c6538c2ddd4cb31680c38 +2026-06-11-structured-error-taxonomy.md: 5a5f75038b6124457d2d4d08cbe0bec80415a387 +2026-06-11-structured-error-taxonomy.zh.md: d5a45a447ca00d48cb8366911a10f34c584d915c diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md index 2baf88a1f9..5a5f75038b 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md @@ -1,9 +1,9 @@ # RFC: Structured error taxonomy -English | [中文](2026-06-11-structured-error-taxonomy.zh.md) - Status: implemented +English | [中文](2026-06-11-structured-error-taxonomy.zh.md) + ## Problem Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in `new Error(String(x))`, dropping any code. And `LlmError` was the only typed error in the system, with no shared base, so there was nothing for a consumer to `instanceof` against generically. diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md index 90b0fdd7f6..d5a45a447c 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md @@ -1,4 +1,4 @@ -# RFC:结构化错误分类体系 +# RFC: 结构化错误分类体系 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml index 5f5b2ef266..57fe989a5d 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-tool-schemas-in-prompt-assembly.md: 443e6f20115e5a76001b4466c2d756675adbd886 -2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 03624b98fabdedf691f3fb448cc5f37ba5a649ef +2026-06-11-tool-schemas-in-prompt-assembly.md: 260d56ffab054d874e13eb34eac029bf59b381fc +2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 5ae98dfac676574d00f34580ec079bb6a8f2331f diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md index 443e6f2011..260d56ffab 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md @@ -1,9 +1,9 @@ # RFC: Tool schemas are part of the system-prompt assembly -English | [中文](2026-06-11-tool-schemas-in-prompt-assembly.zh.md) - Status: implemented +English | [中文](2026-06-11-tool-schemas-in-prompt-assembly.zh.md) + ## Problem On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment. diff --git a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md index 03624b98fa..5ae98dfac6 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md @@ -1,4 +1,4 @@ -# RFC:工具 schema 是系统提示词组装的一部分 +# RFC: 工具 schema 是系统提示词组装的一部分 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index d0eac81287..fcdc63390f 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-13-capability-seams.md: e9d417dbd2bafcaece39601b12dbb310feb1e19b -2026-06-13-capability-seams.zh.md: c569f3df083ec48bd05e6be2d3a1e5875fde362f +2026-06-13-capability-seams.md: f10182737347fc54ff3fd06edb299e569397773d +2026-06-13-capability-seams.zh.md: e65d2f31eb80ce80e92b3e4bbb4d5b8948185c44 diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md index e9d417dbd2..f101827373 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.md @@ -1,9 +1,9 @@ # RFC: Capability seams — interface / implementation / consumer split -English | [中文](2026-06-13-capability-seams.zh.md) - Status: implemented +English | [中文](2026-06-13-capability-seams.zh.md) + ## Problem The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer surface* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. diff --git a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md index c569f3df08..e65d2f31eb 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -1,9 +1,9 @@ -# RFC:能力 seam——接口/实现/消费方三分 - -[English](2026-06-13-capability-seams.md) | 中文 +# RFC: 能力 seam——接口/实现/消费方三分 Status: implemented +[English](2026-06-13-capability-seams.md) | 中文 + ## 问题 harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面向什么编程)。将三者捆绑在一个包(package)中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 @@ -25,7 +25,7 @@ harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化 ## 曾考虑的替代方案 - **单一合并包**:否决。因为它重新耦合了三分设计本要分离的三种变化速率(这正是拆分的意义所在)。 -- **`@cordisjs/plugin-capability`**:这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,**不是**替换实现的机制。混淆这两个「能力」概念正是本 RFC 所指出的陷阱。 +- **`@cordisjs/plugin-capability`**:这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,不是替换实现的机制。混淆这两个「能力」概念正是本 RFC 所指出的陷阱。 ## 后果 diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index df04a14d6a..545aae3758 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-13-twin-llm-adapters.md: 4efefcf4e2f6b1d60567ba3bfed7ae1ea53a7a4e -2026-06-13-twin-llm-adapters.zh.md: 6cabd95c5361afdea5b33ffdee34a5acb6026f7f +2026-06-13-twin-llm-adapters.md: edd7080e2a16e9f1af2ebd56a4265040dc970c17 +2026-06-13-twin-llm-adapters.zh.md: 2087d498f65326fa0a2cb74be409a926a5e343f5 diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md index 4efefcf4e2..edd7080e2a 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -1,9 +1,9 @@ # RFC: Two LLM adapters as a design-verification twin -English | [中文](2026-06-13-twin-llm-adapters.zh.md) - Status: implemented +English | [中文](2026-06-13-twin-llm-adapters.zh.md) + ## Problem `dsh-llm` owns a provider-neutral streaming vocabulary — the `StreamChunk` protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`) and the content-block types ([the content-block vocabulary](2026-06-11-content-block-vocabulary.md)). A vocabulary defined against a single adapter risks baking that adapter's quirks into the "neutral" contract: anything the one implementation happens to do becomes the de-facto spec, and the abstraction is unverified until a second provider arrives — by which point the leak is expensive to fix. diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 6cabd95c53..2087d498f6 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -1,4 +1,4 @@ -# RFC:以两个 LLM 适配器作为设计验证孪生体 +# RFC: 以两个 LLM 适配器作为设计验证孪生体 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index c6b9eaf967..aa7fe88f24 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-14-session-persistence.md: 4078487b71862791dd25cf2afcfc03255cfeb0be -2026-06-14-session-persistence.zh.md: a44ef9647ca4003bc81023a54b7e9b5945003b02 +2026-06-14-session-persistence.md: a50c076746981892239304266194d1e8e309574f +2026-06-14-session-persistence.zh.md: d609e663bd9e36f74bf34404c021a30f54d3bd76 diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 4078487b71..a50c076746 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -1,9 +1,9 @@ # RFC: Session persistence as an abstract service over the existing `SessionEvent` -English | [中文](2026-06-14-session-persistence.zh.md) - Status: implemented +English | [中文](2026-06-14-session-persistence.zh.md) + ## Problem Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md index a44ef9647c..d609e663bd 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -1,4 +1,4 @@ -# RFC:会话持久化作为基于现有 `SessionEvent` 的抽象服务 +# RFC: 会话持久化作为基于现有 `SessionEvent` 的抽象服务 Status: implemented @@ -23,7 +23,7 @@ Status: implemented - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写到 `turn/end` 的事件永不被重写,且循环仅在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当 `sessionPersistence` 不存在时,`resume` 以明确的错误拒绝。 +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 以明确的错误拒绝。 ## 曾考虑的替代方案 diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml index 72eb9e3d7a..cf8898285a 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-15-turn-enclosure-invariant.md: bf0789f21ba7bd928e023bb5fd844d9ec78c4bc1 -2026-06-15-turn-enclosure-invariant.zh.md: 644e7b975e024286d5307f586f509c2b4a9f21ea +2026-06-15-turn-enclosure-invariant.md: 26ae1890e481288b3be17f97db14c332f69be4b8 +2026-06-15-turn-enclosure-invariant.zh.md: 1946f829100e39afed826b8cf3ba82d22058c27d diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index bf0789f21b..26ae1890e4 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -1,9 +1,9 @@ # RFC: Every session event is enclosed in a turn -English | [中文](2026-06-15-turn-enclosure-invariant.zh.md) - Status: implemented +English | [中文](2026-06-15-turn-enclosure-invariant.zh.md) + ## Problem A durable session-persistence backend (added in a companion change) uses the **turn** as its crash-recovery boundary: a crash can leave an unclosed final turn, which `load` closes with a synthetic `turn/end {kind:'interrupted'}` while preserving the turn's real events (see [session persistence](2026-06-14-session-persistence.md)). This recovery is only well-defined if nothing *legitimately* durable sits OUTSIDE a turn — between the last `turn/end` and the next `turn/start` — since such an event would be swept into the next turn's interrupted close. diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md index 644e7b975e..1946f82910 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md @@ -1,4 +1,4 @@ -# RFC:每个会话事件都封闭在一个轮次内 +# RFC: 每个会话事件都封闭在一个轮次内 Status: implemented @@ -10,7 +10,7 @@ Status: implemented 这一假设并不成立。有两条路径在任何轮次之外记录了事件: -1. **排队的用户消息。** agent loop(智能体循环)排空排队消息并在 `turn/start` **之前**追加 `user/message`——于是一个轮次自身的提示词落在了前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。 +1. **排队的用户消息。** agent loop(智能体循环)排空排队消息并在 `turn/start` *之前*追加 `user/message`——于是一个轮次自身的提示词落在了前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。 2. **空闲时的上下文注入。** `agent.inject()` 直接追加一条 `context/message`。它在生产环境中的真实调用方是 `dsh-tool-bash`,后者从 `ctx.bash.onTaskDone` 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,而这经常发生在 agent **空闲**(轮次之间)时。 在情况 2 中,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end`),`scanLog` 会将其视为崩溃残留并在**恢复时丢弃**——注入的上下文已持久写入磁盘,但重新加载后被静默丢失。情况 1 本身无害(`user/message` 之后总会跟着它触发的轮次),但使「什么可以出现在轮次之外」这条规则变得模糊。 diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 11faf45d26..62ab944e40 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-filesystem-capability-seam.md: c502ae712de22e97661192057d4410c7c55ea044 -2026-06-17-filesystem-capability-seam.zh.md: 4e544e24c548a52bde254886a65ff2fdb559a986 +2026-06-17-filesystem-capability-seam.md: c87c8a44d2e956a9613039378d26f72e8ee97ee7 +2026-06-17-filesystem-capability-seam.zh.md: 4864e77ced5c078fc8c1e970a2ff88c78a37af2a diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index c502ae712d..c87c8a44d2 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -1,9 +1,9 @@ # RFC: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools -English | [中文](2026-06-17-filesystem-capability-seam.zh.md) - Status: implemented +English | [中文](2026-06-17-filesystem-capability-seam.zh.md) + ## Problem The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once. diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 4e544e24c5..4864e77ced 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -1,9 +1,9 @@ -# RFC:文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具 - -[English](2026-06-17-filesystem-capability-seam.md) | 中文 +# RFC: 文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具 Status: implemented +[English](2026-06-17-filesystem-capability-seam.md) | 中文 + ## 问题 harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read`、`write` 和 `edit` 直接使用 `node:fs`,面向模型的工具包将同时承担文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。 @@ -80,10 +80,10 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 解析后的目标必须至少暴露三个概念: - 原始输入路径,用于诊断。 -- 不透明的 `targetKey`,用于过期守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 +- 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 -读取和变更结果必须包含不透明的文件 `version`。本地后端可以使用 mtime/size 或类似 hash 的令牌;远程后端可以使用 revision id。`dsh-fs-policy` 插件记录版本用于过期检查;消费方可以展示相关元数据但禁止解释版本令牌。 +读取和变更结果必须包含不透明的文件 `version`。本地后端可以使用 mtime/size 或类似 hash 的令牌;远程后端可以使用 revision id。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。 @@ -91,7 +91,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已有的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`,这是策略为未观测 owner 使用的路径);`replaceIfVersion` 仅在目标处于观测版本时替换,否则报 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。 -字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的过期版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;过期检查在字面匹配之前运行,因此基于旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地风格的组合。 +字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的陈旧版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;陈旧检查在字面匹配之前运行,因此基于旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地风格的组合。 策略插件(而非 `ctx.fs`)对先前观测进行门控:`edit` 要求 owner 有先前观测(否则报 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传给 `editText`。在策略插件缺席时,`ctx.fs` 本身是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。 @@ -129,8 +129,8 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 本仓库曾踩过的防御性模式类别被直接固定: - **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only(`'wx'`、`0o600`)临时文件暂存,失败时清理,最后原子 rename——与 bash 溢出文件规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。 -- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的过期写入可通过另一个路径检测到。 -- **并发/过期竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个被 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。 +- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的陈旧写入可通过另一个路径检测到。 +- **并发/陈旧竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个被 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。 - **HMR(热模块替换)安全与 dispose(资源释放)。** dispose 后端的 fiber 会撤回 `ctx.fs` 提供方;后续的提供方以无继承状态启动。 ## 曾考虑的替代方案 @@ -155,6 +155,6 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` **观测状态持久化被推迟。** 观测状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观测可回放。 -**错误码成为 seam 的一部分。** `FsError` 错误码使过期版本和观测失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs` 从 `dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且限于错误词汇。 +**错误码成为 seam 的一部分。** `FsError` 错误码使陈旧版本和观测失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs` 从 `dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且限于错误词汇。 **包拆分的成本前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改包接口代价更高。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml index 27f4b260ae..34098ed665 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-agent-lifecycle-and-ownership-seams.md: a70e7db8d809efd68ae770995795fc7b3d1b83d2 -2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 3fb68336c56b5296f18b3587ea42399a05362733 +2026-06-18-agent-lifecycle-and-ownership-seams.md: c37578020366dd40250c061e34a86d2de6907e19 +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 1f3cb609da5508d22046f445e08a75dc5a7901cd diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index a70e7db8d8..c375780203 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -1,9 +1,9 @@ # RFC: Agent lifecycle and ownership seams -English | [中文](2026-06-18-agent-lifecycle-and-ownership-seams.zh.md) - Status: implemented +English | [中文](2026-06-18-agent-lifecycle-and-ownership-seams.zh.md) + ## Problem Several ACP and tool-bash limitations were symptoms of the same missing seam: plugins could create or resume agents through `ctx.agents`, but they could not own and dispose one agent independently, and long-running bash tasks carried no stable owner in the executor itself. ACP aborted and awaited agents on disconnect but could not unregister just that session's agent; `session/cancel` could not cancel queued-but-not-yet-started work; and `tool-bash` kept task ownership in a plugin-local `Map`, so an HMR reload could make an old task look unowned. diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md index 3fb68336c5..1f3cb609da 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -1,9 +1,9 @@ -# RFC:Agent 生命周期与所有权 seam - -[English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文 +# RFC: Agent 生命周期与所有权 seam Status: implemented +[English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文 + ## 问题 ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 seam 的症状:插件可以通过 `ctx.agents` 创建或恢复 agent(智能体),但无法独立拥有和 dispose(资源释放)单个 agent,而长时间运行的 bash 任务在执行器中也没有稳定的所有者。ACP 在断连时中止并等待 agent,却无法仅注销该会话的 agent;`session/cancel` 无法取消已入队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml index 98b4ee0e59..c319a372fe 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-session-surface.md: 31297166b735468147850a81d7fd43a8fa30a1e8 -2026-06-18-session-surface.zh.md: 159aefc10261ac5380701f46c3d4a367674940b1 +2026-06-18-session-surface.md: bcad236eaa2bead3c140e7a12772729710d43f83 +2026-06-18-session-surface.zh.md: 2ec60501c183d1bdab22ceb67b7f6aa0040ae82b diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 31297166b7..bcad236eaa 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -1,9 +1,9 @@ # RFC: Session surface — a linked list over the event log for LLM message derivation -English | [中文](2026-06-18-session-surface.zh.md) - Status: implemented +English | [中文](2026-06-18-session-surface.zh.md) + ## Problem The event log is authoritative, but history manipulation had no durable shared mechanism. Plugins such as compaction would otherwise rewrite derived requests through order-sensitive listeners, leave no provenance, and require repeated changes to `deriveMessages()`. diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md index 159aefc102..2ec60501c1 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.zh.md @@ -1,9 +1,9 @@ -# RFC:会话 surface——基于事件日志的链表,用于 LLM 消息派生 - -[English](2026-06-18-session-surface.md) | 中文 +# RFC: 会话 surface——基于事件日志的链表,用于 LLM 消息派生 Status: implemented +[English](2026-06-18-session-surface.md) | 中文 + ## 问题 事件日志是权威数据源,但历史操纵此前没有持久化的共享机制。如果没有这样的机制,上下文压缩(context compaction)等插件只能通过顺序敏感的监听器改写派生请求,不留溯源信息,且每次新增操纵都要反复修改 `deriveMessages()`。 diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index f693539b91..e960781d12 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-shared-persistence-write-coordinator.md: 3fc30dc2e1382fd983d050433123f46a2cd0ed19 -2026-06-18-shared-persistence-write-coordinator.zh.md: 38e900d38cc48317836717ddeda5323cf97df993 +2026-06-18-shared-persistence-write-coordinator.md: 7648c6f8e43fb8c78f881838aa4d476aba209dca +2026-06-18-shared-persistence-write-coordinator.zh.md: 19f583c2f3d78fcdac1378cfe00e49c0be943eb1 diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 3fc30dc2e1..7648c6f8e4 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -1,9 +1,9 @@ # RFC: Shared persistence write coordinator -English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) - Status: implemented +English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) + ## Problem `dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind buffers, serialized flush chains, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. A code-level diff showed the two backends were byte-identical — or same-algorithm — for ALL of it: the four maps (`states`/`buffers`/`chains`/`inits`), `installWritePath`, `initFor`, `onCreated`'s four cases, `flush`, `drain`, `serialize`, `adopt`, `adoptLivePrefix`, `assertVersion`, and the `create`/`append`/`load` skeletons. Only the storage primitives (write bytes vs. INSERT rows) differed. diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 38e900d38c..19f583c2f3 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -1,12 +1,12 @@ -# RFC:共享持久化写入协调器 - -[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文 +# RFC: 共享持久化写入协调器 Status: implemented +[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文 + ## 问题 -`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但它们的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、序列化的 flush 链、HMR(热模块替换)种子注入与 dispose(资源释放)排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。代码级 diff 表明两个后端在**全部**这些逻辑上要么字节相同、要么算法相同:四个 map(`states`/`buffers`/`chains`/`inits`)、`installWritePath`、`initFor`、`onCreated` 的四种分支、`flush`、`drain`、`serialize`、`adopt`、`adoptLivePrefix`、`assertVersion`,以及 `create`/`append`/`load` 的骨架。唯一的差异在于存储原语(写字节 vs. INSERT 行)。 +`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但它们的写入路径编排是重复的:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、序列化的 flush 链、HMR(热模块替换)种子注入与 dispose(资源释放)排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。代码级 diff 表明两个后端在全部这些逻辑上要么字节相同、要么算法相同:四个 map(`states`/`buffers`/`chains`/`inits`)、`installWritePath`、`initFor`、`onCreated` 的四种分支、`flush`、`drain`、`serialize`、`adopt`、`adoptLivePrefix`、`assertVersion`,以及 `create`/`append`/`load` 的骨架。唯一的差异在于存储原语(写字节 vs. INSERT 行)。 ## 决策 @@ -19,16 +19,16 @@ Status: implemented 六个方法(五个必需 + 一个可选的生命周期钩子)——协调器与存储之间唯一的 seam: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`——按 id 读取已存储的前缀,扫描**任何**存储范围(JSONL 的每个 cwd bucket;SQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 进行创建碰撞探测。 -- `loadLive(id, cwd)`——读取**限定于 `cwd`** 的已存储前缀。**与 `loadStored` 有意区分**:HMR live-adoption 只能接管与存活会话处于**同一 cwd** 的持久化日志;同 id 但不同 cwd 的日志是碰撞而非恢复。合并二者会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`。 -- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时**原子地**惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 +- `loadStored(id)`——按 id 读取已存储的前缀,扫描任何存储范围(JSONL 的每个 cwd bucket;SQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 进行创建碰撞探测。 +- `loadLive(id, cwd)`——读取限定于 `cwd` 的已存储前缀。**与 `loadStored` 有意区分**:HMR live-adoption 只能接管与存活会话处于同一 cwd 的持久化日志;同 id 但不同 cwd 的日志是碰撞而非恢复。合并二者会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`。 +- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 -- `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空**之后**被 await,因此 close 失败不会掩盖排空错误。 +- `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空之后被 await,因此 close 失败不会掩盖排空错误。 ### 不透明的 torn marker -保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是**不透明的**。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 使用要截断到的字节偏移,SQLite 使用要从其开始删除的 seq(两者恰好都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较折叠**在钩子内部**,因此返回的 marker 已经是 `number | undefined`;如果不做这层折叠,协调器就必须了解字节长度。 +保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 使用要截断到的字节偏移,SQLite 使用要从其开始删除的 seq(两者恰好都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较折叠在钩子内部,因此返回的 marker 已经是 `number | undefined`;如果不做这层折叠,协调器就必须了解字节长度。 ## 测试 diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index 9fc5b9299f..7d33e94f0a 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-branded-ids.md: f6d066857d8904ae5343f12310266663806a0ae2 -2026-06-20-branded-ids.zh.md: 80c158e598f3007416d31a89a6704a759798e44e +2026-06-20-branded-ids.md: aab47a1413451edb707ae797e5b99cda6e34efad +2026-06-20-branded-ids.zh.md: 1ccfda3282069510bcdbfbddf4546305fd0923a5 diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index f6d066857d..aab47a1413 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -1,9 +1,9 @@ # RFC: Branded IDs everywhere they belong -English | [中文](2026-06-20-branded-ids.zh.md) - Status: implemented +English | [中文](2026-06-20-branded-ids.zh.md) + ## Problem The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md index 80c158e598..1ccfda3282 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -1,16 +1,16 @@ -# RFC:在所有应有之处使用 branded ID - -[English](2026-06-20-branded-ids.md) | 中文 +# RFC: 在所有应有之处使用 branded ID Status: implemented +[English](2026-06-20-branded-ids.md) | 中文 + ## 问题 harness 已经为三个标识符做了 brand 处理:`CallId`(`packages/llm/llm/src/brand.ts`)、`SessionId`(`packages/core/session/src/types.ts`)和 `AgentId`(`packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制(由纯类型包(package) `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*"Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。"* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 **缺口 1:bash seam 中未 brand 的 ID。** `BashTask.id` 以及所有执行器/工具边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型还通过 `task_id` 返回该值,因此混淆 task id 和 session id 既类型正确又可达。 -bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)的 `session.header.id`(`callerToken = (exec) => exec.agent?.session.header.id`,位于 `packages/bash/tool-bash/src/index.ts`),即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的"bash owner-token alias hole"。 +bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)的 `session.header.id`(`callerToken = (exec) => exec.agent?.session.header.id`,位于 `packages/bash/tool-bash/src/index.ts`),即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的以 `session.header.id` 作为 owner 的别名缺口("bash owner-token alias hole")。 **缺口 2:既有 brand 的侵蚀。** `CallId`、`SessionId` 和 `AgentId` 在注册表 map、公开查找参数、ACP 会话跟踪和持久化协调器中退化为裸 string。在查找边界丢弃 brand 会使其主要保护失效。 @@ -18,7 +18,7 @@ bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵循既有的"不是每个 string 都需要"策略。 -- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`(拥有该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的工具边界处被 brand)。 +- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的工具边界处被 brand)。 - **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash seam 从不导入 `dsh-session`。(理由见下一节。) @@ -46,7 +46,7 @@ export function OwnerToken(id: string): OwnerToken { ### 为什么不把 `owner` 类型标注为 `SessionId`? -执行器将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保留了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行从 `SessionId` 到 `OwnerToken` 的唯一转换。 +执行器将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保留了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行来自 `SessionId` 的唯一转换。 ## 不在范围内 / 可能的扩展 @@ -65,5 +65,5 @@ export function OwnerToken(id: string): OwnerToken { ## 后果 - **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);如果该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 -- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 RFC 不关闭这个缺口(见"不在范围内")——它只阻止传入错误*类别*的 id 这种错误。 +- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 RFC 不关闭这个缺口(见"不在范围内")——它只阻止这类*类别*错误:传入错误*种类*的 id。 - **"在哪里停下"仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string"可能被混淆"的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml index d10d531a66..602102a219 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-extract-example-app-packages.md: 3a0a0f5d4b329afed72bd3c00bf880989e24fe54 -2026-06-20-extract-example-app-packages.zh.md: 8945f0c97727479c9e847711a96fc5d18118e09e +2026-06-20-extract-example-app-packages.md: b0dd1fe32b2389578d3f3bd3672435923eb1a5d1 +2026-06-20-extract-example-app-packages.zh.md: 9f47c98c9c54357a304490e3d4075fad6cea855a diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index 3a0a0f5d4b..b0dd1fe32b 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -1,9 +1,9 @@ # RFC: Extract example apps into packages -English | [中文](2026-06-20-extract-example-app-packages.zh.md) - Status: implemented +English | [中文](2026-06-20-extract-example-app-packages.zh.md) + ## Problem An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes. diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md index 8945f0c977..9f47c98c9c 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md @@ -1,9 +1,9 @@ -# RFC:将示例应用提取为独立包 - -[English](2026-06-20-extract-example-app-packages.md) | 中文 +# RFC: 将示例应用提取为独立包 Status: implemented +[English](2026-06-20-extract-example-app-packages.md) | 中文 + ## 问题 示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。 diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml index 08ab9fc7f0..05549751eb 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-package-hierarchy.md: faf5815222b20699a32f1af489e625ba3e891230 -2026-06-20-package-hierarchy.zh.md: 118367f2655bffbd270f259df4ade37a70dcb2d7 +2026-06-20-package-hierarchy.md: a06e963d118d895cc55a0f5e378bbf12678f3491 +2026-06-20-package-hierarchy.zh.md: a136f15a4197645b06fa0a68564a9e0464af1cba diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md index faf5815222..a06e963d11 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -1,9 +1,9 @@ # RFC: Reorganize packages into a modular hierarchy -English | [中文](2026-06-20-package-hierarchy.zh.md) - Status: implemented +English | [中文](2026-06-20-package-hierarchy.zh.md) + ## Problem `packages/` was flat: 18 packages all sat at `packages/<name>/`, so a package's location said nothing about whether it was core product API, a swappable capability seam, a provider adapter, a product integration, or example/test support. The package README carried a `FIXME(package-hierarchy)` and `scripts/publint-all.ts` a `TODO(package-inventory)` flagging exactly this. Core packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all looked equally foundational. diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md index 118367f265..a136f15a41 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -1,9 +1,9 @@ -# RFC:将包重组为模块化层级结构 - -[English](2026-06-20-package-hierarchy.md) | 中文 +# RFC: 将包重组为模块化层级结构 Status: implemented +[English](2026-06-20-package-hierarchy.md) | 中文 + ## 问题 `packages/` 原先是扁平的:18 个包(package)全部位于 `packages/<name>/`,从路径上完全看不出一个包属于核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。包的 README 带着 `FIXME(package-hierarchy)`,`scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来同样基础。 diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 4a0b9d6ebc..decd9cec05 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-21-mandatory-app-attribution-headers.md: 4fa773e089b3a4b682e42269a66d85aeaf5c18f6 -2026-06-21-mandatory-app-attribution-headers.zh.md: 42cc396b5719adb2a2d71e0e9cf0d3554c5533a5 +2026-06-21-mandatory-app-attribution-headers.md: 132c827b48693af85fde67cf9d2d71cc84e853c6 +2026-06-21-mandatory-app-attribution-headers.zh.md: 9bba6d912b0b9bb33174c1ebb51a84dfdb47af0f diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 4fa773e089..132c827b48 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -1,9 +1,9 @@ # RFC: Mandatory `User-Agent` attribution for provider requests -English | [中文](2026-06-21-mandatory-app-attribution-headers.zh.md) - Status: implemented +English | [中文](2026-06-21-mandatory-app-attribution-headers.zh.md) + ## Problem LLM provider requests should identify the product making them. That is useful for provider-side support, abuse investigation, compatibility debugging, and traffic analytics. Before this RFC the harness only partially did this: the hand-rolled DeepSeek adapter sent a hand-copied `User-Agent` constant (`packages/llm/llm-deepseek/src/adapter.ts`), while the pi-ai-backed twin sent no harness-owned headers at all (`packages/llm/llm-pi-ai/src/adapter.ts`). New adapters could therefore omit attribution silently, and a library-backed adapter could drift from the hand-rolled adapter even though [the twin-adapter RFC](2026-06-13-twin-llm-adapters.md) exists to keep the provider seam honest across both implementations. diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 42cc396b57..9bba6d912b 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -1,9 +1,9 @@ -# RFC:对提供方请求强制携带 `User-Agent` 归属标识 - -[English](2026-06-21-mandatory-app-attribution-headers.md) | 中文 +# RFC: 对提供方请求强制携带 `User-Agent` 归属标识 Status: implemented +[English](2026-06-21-mandatory-app-attribution-headers.md) | 中文 + ## 问题 LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 @@ -15,9 +15,9 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - **OpenRouter 的机制是提供方特有的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title` 和 `X-OpenRouter-Categories` 来追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。 - **在 agent 工具生态中,`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACP(Agent Client Protocol)等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界中被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。 - **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特有的附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。 -- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件身份,说明它用于互操作性报告和分析,并说用户代理*应当*在每个请求中发送它(除非被配置为不发送)。这是唯一直接对应「哪个产品在发出此 HTTP 请求」的标准头部。 +- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件身份,说明它用于互操作性报告和分析,并说用户代理应当在每个请求中发送它(除非被配置为不发送)。这是唯一直接对应「哪个产品在发出此 HTTP 请求」的标准头部。 - **`Referer` 是标准的,但 OpenRouter 的 `HTTP-Referer` 不是标准字段。** RFC 9110 第 10.1.3 节将 `Referer` 定义为获取目标 URI 的来源 URI,并用大量篇幅讨论隐私限制。OpenRouter 则要求 `HTTP-Referer`,将其用作应用 URL 标识符。该名称和含义是 OpenRouter 特有的,尽管它形似标准 `Referer` 头部的 CGI 环境变量形式。 -- **`From` 是标准的,但不适合作为强制默认值。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人的电子邮件地址。机器人代理*应当*发送它以便服务器联系运营者,但非机器人代理出于隐私和安全策略考虑不应在未经用户显式配置的情况下发送。harness 可以后续支持运营者联系方式,但不得凭空捏造或全局强制要求。 +- **`From` 是标准的,但不适合作为强制默认值。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人的电子邮件地址。机器人代理应当发送它以便服务器联系运营者,但非机器人代理出于隐私和安全策略考虑不应在未经用户显式配置的情况下发送。harness 可以后续支持运营者联系方式,但不得凭空捏造或全局强制要求。 - **请求体中的 `user` 或 `metadata` 字段不是应用归属。** 部分模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特有的 body schema,要么不保证能通过 OpenAI 兼容网关透传。它们不能替代静态的应用身份头部。 - **SDK 遥测头部标识的是 SDK,而非应用。** 官方和第三方 SDK 常发送库/版本头部。这些帮助 SDK 维护者调试其客户端,但除非应用显式提供产品归属层,否则它们不能标识 harness 作为应用。 - **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai` 的 `StreamOptions.headers` 将调用方头部最后合并(覆盖提供方默认值),因此基于库的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。 diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 7a19846a47..b164c4c0f7 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-24-web-capability-seam.md: 3d0cdd89bb8366749ee0b3959244db1c57c0ccaa -2026-06-24-web-capability-seam.zh.md: 1cb1169f99b6eed22bcca650e0b3fe184f331307 +2026-06-24-web-capability-seam.md: fdb611be9efbf29717e41ffa2db86c55258ebe2e +2026-06-24-web-capability-seam.zh.md: f5db538f0565c4fc5667dcbec0791579246d238d diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 3d0cdd89bb..fdb611be9e 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -1,9 +1,9 @@ # RFC: Web capability seam - stable tools over multiple providers -English | [中文](2026-06-24-web-capability-seam.zh.md) - Status: implemented +English | [中文](2026-06-24-web-capability-seam.zh.md) + ## Problem The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized seam does not just mirror one vendor. Fetch is a separate capability: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 1cb1169f99..f5db538f05 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -1,4 +1,4 @@ -# RFC:Web 能力 seam——稳定的工具覆盖多个提供方 +# RFC: Web 能力 seam——稳定的工具覆盖多个提供方 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index 06f5fb57d8..609b27aa6a 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-26-file-context-as-event-gate.md: 6e78e2df5f7969b5ed9b74c0b597e2fcacbe8e82 -2026-06-26-file-context-as-event-gate.zh.md: d69ccdbcea4b14dbd0291cf69af0bf7d5f3fadfc +2026-06-26-file-context-as-event-gate.md: ac9a8bcbc5bfda2b35cae4993497273608abf35b +2026-06-26-file-context-as-event-gate.zh.md: eda637f9adf8346779c70d59575863bf7e1162c9 diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 6e78e2df5f..ac9a8bcbc5 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -1,9 +1,9 @@ # RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface -English | [中文](2026-06-26-file-context-as-event-gate.zh.md) - Status: implemented +English | [中文](2026-06-26-file-context-as-event-gate.zh.md) + ## Problem [The split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — `dsh-tool-fs` would fail to resolve `ctx.fileContext`. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index d69ccdbcea..eda637f9ad 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -1,4 +1,4 @@ -# RFC:将 `dsh-fs-policy` 改为事件门控插件,而非方法接口 +# RFC: 将 `dsh-fs-policy` 改为事件门控插件,而非方法接口 Status: implemented @@ -72,7 +72,7 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion **两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 `prepend` 的监听器会替代该策略。权限、审计和沙箱关注点仍留在可组合的 `tools/execute` waterfall 上。 -actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或收窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其在监听器中将 `object` actor 收窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它**不**拥有策略层的运行时 owner 结构。 +actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或收窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其在监听器中将 `object` actor 收窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它不拥有策略层的运行时 owner 结构。 ```ts import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' @@ -154,7 +154,7 @@ interface Events { ## 验证 -测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`、write 或 edit 零次 `stat`。面向模型的 schema 逐字节不变,因此快照不变。 +测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`,write 或 edit 均为零次。面向模型的 schema 逐字节不变,因此快照不变。 ## 曾考虑的替代方案 diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 3bb8d3e607..d4d7cb2482 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 72aae03361cbc088cf64f3548a43ac6253eb21eb -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: f661199048b7eaa359f792e96ac52baf8cd61fdf +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 712c17532bc1e6b95479aec5b544bc7e296952ec +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: db50f08dae30aa0c43e741a4f27f948f712954e0 diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 72aae03361..712c17532b 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -1,9 +1,9 @@ # RFC: stdin + extra env on the bash seam -English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md) - Status: implemented +English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md) + ## Problem The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This RFC adds those two inputs. diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index f661199048..db50f08dae 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -1,4 +1,4 @@ -# RFC:在 bash seam 上支持 stdin 与额外 env +# RFC: 在 bash seam 上支持 stdin 与额外 env Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 2416919ab7..1b064ea80a 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-event-domain-semantics.md: e05c238c52052454d3e01e82767cddd9af316a9d -2026-06-30-event-domain-semantics.zh.md: 9048679ec7a7992852cce76bf43269f5499da792 +2026-06-30-event-domain-semantics.md: a9d33a7bfb549e1ec2102c23af78f91d9ae4b904 +2026-06-30-event-domain-semantics.zh.md: dc7390e3d2e1cda3469fa77389d0d6ad39b7a713 diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index e05c238c52..a9d33a7bfb 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -1,9 +1,9 @@ # RFC: Event-domain semantics — session is the fact log, agent is the live surface -English | [中文](2026-06-30-event-domain-semantics.zh.md) - Status: implemented +English | [中文](2026-06-30-event-domain-semantics.zh.md) + ## Problem The harness extends the agent loop through a Cordis event taxonomy (see [the microkernel event-taxonomy RFC](2026-06-11-microkernel-event-taxonomy.md)). As that taxonomy grew, the line between the three event domains blurred: diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index 9048679ec7..dc7390e3d2 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -1,4 +1,4 @@ -# RFC:事件域语义——session 是事实日志,agent 是运行时表面 +# RFC: 事件域语义——session 是事实日志,agent 是运行时表面 Status: implemented @@ -12,7 +12,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 - `tools/*` 承载工具注册表与执行 seam。 -两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)**和**镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要**一个**连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听 session 事件还是 agent 事件,以及原因。 +两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)和镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要一个连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听 session 事件还是 agent 事件,以及原因。 这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code 和 Codex 桥接的基础。 @@ -21,7 +21,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **三个域,各司其职,以一条边界规则统一。** - **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。 -- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤**边界**不在此处——它们是持久的 session 事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。 +- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的 session 事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。 - **`tools/*`——工具注册表与执行 seam。** **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 @@ -31,7 +31,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) ## 后果 - 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。 -- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试「抛出异常的 turn 边界 emit 监听器」的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 +- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的 turn 边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 - 完整实现见[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 - Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml index 3cff8c7aca..1af8dd8fe3 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-fs-per-session-cwd.md: 00643955d918dff87241f4b240bb7e6774d21a0b -2026-07-02-fs-per-session-cwd.zh.md: 73176cde3747a2eb8c03aadbf3f419bf27173d70 +2026-07-02-fs-per-session-cwd.md: 2513294d6bc991ee39155bbf20869829cca1a188 +2026-07-02-fs-per-session-cwd.zh.md: 2c3957113642fe2bf7b3a4f2b6186246b32aff3f diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md index 00643955d9..2513294d6b 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -1,9 +1,9 @@ # RFC: Resolve filesystem paths against the caller's session cwd -English | [中文](2026-07-02-fs-per-session-cwd.zh.md) - Status: implemented +English | [中文](2026-07-02-fs-per-session-cwd.zh.md) + ## Problem The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd RFC work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces. diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md index 73176cde37..2c39571136 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md @@ -1,9 +1,9 @@ -# RFC:相对文件系统路径按调用方的会话 cwd 解析 - -[English](2026-07-02-fs-per-session-cwd.md) | 中文 +# RFC: 相对文件系统路径按调用方的会话 cwd 解析 Status: implemented +[English](2026-07-02-fs-per-session-cwd.md) | 中文 + ## 问题 ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。 @@ -24,7 +24,7 @@ ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区 提供方 seam 不得依赖 `dsh-agent`/`dsh-session`——它是一个文本存储后端,沙箱或远程实现同样满足该接口,而这些实现没有「agent 会话」的概念。工具已经接收了 `ToolExecution`(`exec`),其中携带 agent,因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包(package)边界处显式优于隐式」的约定:基准目录作为显式参数传入,提供方据此行动,而非让提供方越界去读取它不应知晓的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。 -默认值只存在于**一个**地方——提供方的 `config.cwd`。`sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会自行制造一个提供方本应自行选择的基准目录。 +默认值只存在于一个地方——提供方的 `config.cwd`。`sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会自行制造一个提供方本应自行选择的基准目录。 ## 后果 diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml index 0401c48e08..d05c13ff78 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-result-time-applied-hunk-diffs.md: 81ab8b9827ddaec39d63e2a3f8fbb864085a9ac9 -2026-07-02-result-time-applied-hunk-diffs.zh.md: 2914c4242c4246ede8588967ed36b3f6c725c607 +2026-07-02-result-time-applied-hunk-diffs.md: fd02951f21319be2933bf3ffaddeb96bc2a6819c +2026-07-02-result-time-applied-hunk-diffs.zh.md: 4ed9a29bb023343cab28b8114453d17408bc429a diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 81ab8b9827..fd02951f21 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -1,9 +1,9 @@ # RFC: Result-time applied-hunk diffs for file mutations -English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md) - Status: implemented +English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md) + ## Problem The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gave `dsh-tool-fs` write/edit a `card:'diff'` at CALL time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). An editor renders that as an inline diff, but it is a **context-free** diff — the bare `old_string`→`new_string` with no surrounding lines, and a `replace_all` that touched five scattered sites still renders as one snippet pair. diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md index 2914c4242c..4ed9a29bb0 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md @@ -1,14 +1,14 @@ -# RFC:结果时刻的 applied-hunk diff 用于文件变更 - -[English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文 +# RFC: 结果时刻的 applied-hunk diff 用于文件变更 Status: implemented +[English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文 + ## 问题 [tagged render-intent union](2026-07-02-tool-render-intent-union.md) 为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。 -在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中**原位**显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。 +在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。 障碍在于一个 seam 边界:`presentResult(args, result)` 是 **`args` + 面向模型的 `result`(`{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数和版本号,没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。 @@ -37,7 +37,7 @@ type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unkn ### 3. Bridge 渲染 `diff` 结果卡片 -`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`;bridge 结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中**替换**调用时的内容,因此结果 diff **取代**调用时刻的片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,再结果 diff)与 `claude-agent-acp` 完全一致。 +`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`;bridge 结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中替换调用时的内容,因此结果 diff **取代**调用时刻的片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,再结果 diff)与 `claude-agent-acp` 完全一致。 ## 曾考虑的替代方案 diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 8c2f7faf6d..607ff343ce 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-tool-render-intent-union.md: 8256f09f9c297658627d0c3d9e99ee1c5424b254 -2026-07-02-tool-render-intent-union.zh.md: 35bd775545c9131a20da9e7f7424506e4554eb4b +2026-07-02-tool-render-intent-union.md: 6b828c32fb79f43c0f974f2c9f032e11430de5b4 +2026-07-02-tool-render-intent-union.zh.md: 8c44178ca3e8986579e7ed696aec2f900705e9ad diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md index 8256f09f9c..6b828c32fb 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -1,9 +1,9 @@ # RFC: Tagged render-intent union for tool-call presentation -English | [中文](2026-07-02-tool-render-intent-union.zh.md) - Status: implemented +English | [中文](2026-07-02-tool-render-intent-union.zh.md) + ## Problem A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, `presentCall`/`presentResult` on `ToolDefinition`, returning `ToolCallPresentation` / `ToolResultPresentation` with an optional `ToolTerminal` sub-shape. These grew incrementally into a **bag of optional fields**: `title`, `kind`, `rawInput`, `content`, `locations`, `terminal` on the call; `title`, `content`, `terminal` on the result; `cwd`/`output`/`exitCode`/`signal` on `ToolTerminal`. The split of responsibility is muddy: diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index 35bd775545..8c44178ca3 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -1,4 +1,4 @@ -# RFC:用于工具调用展示的带标签 render-intent 联合类型 +# RFC: 用于工具调用展示的带标签 render-intent 联合类型 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml index f589fd9ddf..b28a70da27 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-03-filesystem-directory-listing-seam.md: bb8d9c4deda18b320b85d548bbd5bcb32f1c1d72 -2026-07-03-filesystem-directory-listing-seam.zh.md: ccc5ca67f58537134da5c5484b3d527ba84fe8d3 +2026-07-03-filesystem-directory-listing-seam.md: 8920e54993eb1402bd310d6ce78fb9866de628c9 +2026-07-03-filesystem-directory-listing-seam.zh.md: 5ca9b195d4bf5c1450c60451dd469a19dd54166f diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md index bb8d9c4ded..8920e54993 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md @@ -1,9 +1,9 @@ # RFC: Add direct directory listing to the filesystem seam -English | [中文](2026-07-03-filesystem-directory-listing-seam.zh.md) - Status: implemented +English | [中文](2026-07-03-filesystem-directory-listing-seam.zh.md) + ## Problem `@deepseek-ai/dsh-fs` is the provider seam for filesystem access, with local and future non-local backends behind the same `ctx.fs` contract. Before this change it could resolve paths, stat targets, read text, stream text, write text, and edit text. That was enough for model-facing file tools, but not for non-model-facing consumers that need to enumerate directories without importing `node:fs`. diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md index ccc5ca67f5..5ca9b195d4 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md @@ -1,9 +1,9 @@ -# RFC:为文件系统 seam 添加直接目录列举能力 - -[English](2026-07-03-filesystem-directory-listing-seam.md) | 中文 +# RFC: 为文件系统 seam 添加直接目录列举能力 Status: implemented +[English](2026-07-03-filesystem-directory-listing-seam.md) | 中文 + ## 问题 `@deepseek-ai/dsh-fs` 是文件系统访问的提供方 seam,本地后端与未来的非本地后端共享同一个 `ctx.fs` 契约。在本次变更之前,它能解析路径、stat 目标、读取文本、流式读取文本、写入文本和编辑文本。这对面向模型的文件工具已经足够,但对于需要枚举目录而又不想直接导入 `node:fs` 的非模型侧消费方来说还不够。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 5c821e12fa..1ea6573424 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: fce9d555c8843b99fdbfa7b652b46d0b88053935 -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 93a639a2ddac33cb1ceac57101cb6185fe6034ad +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 1819a730b214270304a8feb103a7f26e9c8d6e6e +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 4211241b1b24fd461f5341bf8d777007a7a36482 diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index fce9d555c8..1819a730b2 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -1,9 +1,9 @@ # RFC: Prompt variables and tool-guidance ownership -English | [中文](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md) - Status: implemented +English | [中文](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md) + ## Problem The assembled system prompt had four defects, all of one family: facts the harness already knows were restated by hand somewhere else, and drifted. diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 93a639a2dd..4211241b1b 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -1,9 +1,9 @@ -# RFC:Prompt 变量与工具指导归属 - -[English](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 中文 +# RFC: Prompt 变量与工具指导归属 Status: implemented +[English](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 中文 + ## 问题 组装后的系统提示词存在四个缺陷,同属一类:harness 已知的事实在别处被手工重述,然后漂移。 @@ -32,7 +32,7 @@ Status: implemented ### Persona 作为 order-0 section -`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 `0` 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,`agent/pre-step` 因此测量的正是用于压缩(compaction)的确切 prompt。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 +`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,`agent/pre-step` 因此测量的正是用于压缩(compaction)的确切 prompt。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 ### 工具指导归属 @@ -66,7 +66,7 @@ Status: implemented ## 后果 - 组装后的 prompt 中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 -- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,prompt 对该步骤的声明就会过时;如果一个插件在那里**提供**模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 +- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,prompt 对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 - 当一个已绑定的 provider 不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 -- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们**希望**大声暴露的撰写错误。 +- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望大声暴露的撰写错误。 - 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,再行添加。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 2ef938def7..11d51bfef5 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-reconstructable-requests.md: 0978cd8760c6a0420be1bf0a3baf6b50c1a04a13 -2026-07-05-reconstructable-requests.zh.md: a4864c9e795ccc0da2cbbf0ca4d17a90c029858c +2026-07-05-reconstructable-requests.md: b07dbceaf6f3ecad64a464df6b97ee2a32284768 +2026-07-05-reconstructable-requests.zh.md: cb57d0915547b8a38b6afde7e36bec36c86e19c3 diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 0978cd8760..b07dbceaf6 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -1,9 +1,9 @@ # RFC: Every LLM request is reconstructable from the session log -English | [中文](2026-07-05-reconstructable-requests.zh.md) - Status: implemented +English | [中文](2026-07-05-reconstructable-requests.zh.md) + ## Problem The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index a4864c9e79..cb57d09155 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -1,4 +1,4 @@ -# RFC:每个 LLM(大语言模型)请求都可从会话日志重建 +# RFC: 每个 LLM(大语言模型)请求都可从会话日志重建 Status: implemented @@ -46,7 +46,7 @@ Status: implemented ## 后果 - 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。 -- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且**不可能**在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 +- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一快照不同)。提供方自身的 reasoning-content 排除由服务端管理。 - `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。 - 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 74fbfda121..1de1e4c13d 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-subagent-provider-lifecycle-events.md: 6d711f2a6d8496a8a229ec63d86dd89816efb6f8 -2026-07-05-subagent-provider-lifecycle-events.zh.md: f412b031644c14ca70caae3efc72eefa9ce2c2ac +2026-07-05-subagent-provider-lifecycle-events.md: b5300706ce4c53a6d348a86803d221a4dcca9632 +2026-07-05-subagent-provider-lifecycle-events.zh.md: 8694612e5e3d4d65117896e337c170f47b31abb0 diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 6d711f2a6d..b5300706ce 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -1,9 +1,9 @@ # RFC: Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed` -English | [中文](2026-07-05-subagent-provider-lifecycle-events.zh.md) - Status: implemented +English | [中文](2026-07-05-subagent-provider-lifecycle-events.zh.md) + ## Problem [The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index f412b03164..8694612e5e 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -1,12 +1,12 @@ -# RFC:Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed` - -[English](2026-07-05-subagent-provider-lifecycle-events.md) | 中文 +# RFC: Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed` Status: implemented +[English](2026-07-05-subagent-provider-lifecycle-events.md) | 中文 + ## 问题 -[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方**派生**面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在**工具注册时**固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求("在 cordis.yml 中把后端列在工具前面")。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——"异步状态不是同步状态"(见[防御性模式](../../../defensive-patterns.md))。 @@ -17,15 +17,15 @@ Status: implemented - **`subagent/provider-added(provider)`**:一个提供方在 `ctx.subagents` 注册表中变为可解析。在注册时发出。 - **`subagent/provider-removed(name)`**:一个提供方离开注册表(其插件 fiber 被 dispose(资源释放)——卸载或 HMR(热模块替换)重载)。从注册的 disposer 中发出。 -`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞——当提供方离开时注销工具,并在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不会对模型撒谎。这里有意**不留下**任何需要文档化的加载顺序要求:事件让顺序问题消失,而非将其钉死。 +`dsh-tool-subagent` 镜像其命名提供方的生命周期:当提供方可用(或变为可用)时注册工具——在那一刻从该提供方派生措辞——当提供方离开时注销工具,并在重新注册时(HMR 重载)重新派生。提供方不在时工具不存在,因此不会对模型撒谎。这里有意不留下任何需要文档化的加载顺序要求:事件让顺序问题消失,而非将其钉死。 这些事件还完善了 seam 的词汇:`ctx.subagents` 是一个命名注册表,多个委派后端(`spawn`、`fork`、`acp`)在其上共存;一个其他插件从中派生状态的注册表,应当以类型化事件广播成员变化,而非要求轮询或依赖加载顺序。 ## 曾考虑的替代方案 - **在 `apply` 时解析提供方,不存在则抛异常**:否决。"先列后端"这一要求声称了 Loader 并不存在的顺序保证。 -- **重试查找(轮询直到提供方出现)**:最终能收敛,但在框架已有的机制(effect 注册 + disposal)之外发明了一套私有就绪协议;它也无法感知提供方**离开**,因此 HMR 会遗留一个措辞描述已 dispose 后端的工具。 -- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但将 tool-choice 引导移出了**描述**,与 prompt-variables RFC 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 +- **重试查找(轮询直到提供方出现)**:最终能收敛,但在框架已有的机制(effect 注册 + disposal)之外发明了一套私有就绪协议;它也无法感知提供方离开,因此 HMR 会遗留一个措辞描述已 dispose 后端的工具。 +- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但将 tool-choice 引导移出了描述,与 prompt-variables RFC 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 - **根据提供方名称而非提供方对象确定措辞**:`providerName` 本身是配置,重命名后的提供方会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。 ## 后果 @@ -33,4 +33,4 @@ Status: implemented - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 - **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../cordis-catalog/events.md)与[生产者/消费者映射](../../../event-producer-consumer.md)。 - **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持 prompt 组装的时效性。 -- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在**其**提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 +- **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index a051d20478..57b6fca44e 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-timeout-deadline-library.md: 9906aa7cce40ffd7b5f7082199d05edb8d0a54b2 -2026-07-06-timeout-deadline-library.zh.md: dd1a60f9d7b91575b32e1cc85b3800cf823b6a6d +2026-07-06-timeout-deadline-library.md: e8a688fbd5285c5e177eca5339bb204e2e4b01bc +2026-07-06-timeout-deadline-library.zh.md: 427aa4e10fe90a69b8773ed739d92e37754a4998 diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md index 9906aa7cce..e8a688fbd5 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -1,9 +1,9 @@ # RFC: A shared timeout/deadline primitive, with hard-kill left to each capability -English | [中文](2026-07-06-timeout-deadline-library.zh.md) - Status: implemented +English | [中文](2026-07-06-timeout-deadline-library.zh.md) + ## Problem Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index dd1a60f9d7..427aa4e10f 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -1,4 +1,4 @@ -# RFC:共享的超时/截止时间原语,硬终止留给各能力自行实现 +# RFC: 共享的超时/截止时间原语,硬终止留给各能力自行实现 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index 31025407be..1d65850a78 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-tool-call-timeout-policy.md: 0e69c8504dd34dfc4427bf1f3865a80be93eff9d -2026-07-07-tool-call-timeout-policy.zh.md: 2f4eca6eff9d135aad3fe538b887402a42ac6f84 +2026-07-07-tool-call-timeout-policy.md: 20e0b5d70166404e034c0d0ece75cec61cab0df9 +2026-07-07-tool-call-timeout-policy.zh.md: 00b85ce9e2e7ae4ba31a9770beefce9c2eb8c319 diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 0e69c8504d..20e0b5d701 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -1,9 +1,9 @@ # RFC: Tool-call timeout policy as a plugin -English | [中文](2026-07-07-tool-call-timeout-policy.zh.md) - Status: implemented +English | [中文](2026-07-07-tool-call-timeout-policy.zh.md) + ## Problem The [timeout/deadline RFC](2026-07-06-timeout-deadline-library.md) extracted the timing-and-classification primitive into `@deepseek-ai/dsh-timeout`, but timeout policy was still attached to individual capabilities and model-facing schemas. `bash` exposed `timeoutMs`; `web_fetch` exposed `timeout_ms`; `web_search` had no model-facing timeout even though providers already honor `exec.signal`; a future grep/glob tool would either import the timeout library directly or invent its own timeout policy. That is the wrong authoring shape for a plugin SDK: a tool author should normally forward `exec.signal` to the implementation it calls, and deployment policy should decide the budget. diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index 2f4eca6eff..00b85ce9e2 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -1,9 +1,9 @@ -# RFC:工具调用超时策略作为插件 - -[English](2026-07-07-tool-call-timeout-policy.md) | 中文 +# RFC: 工具调用超时策略作为插件 Status: implemented +[English](2026-07-07-tool-call-timeout-policy.md) | 中文 + ## 问题 [超时/截止时间 RFC](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 @@ -36,7 +36,7 @@ ctx.tools.execute(exec) `@deepseek-ai/dsh-tools` 声明了一个 `tools/execute` waterfall,其基础 `next()` 是带规范化的分发 thunk——即同一个内部 `try`/`catch`,将抛出的工具错误(或未知工具错误)转换为 `isError` 的 `ToolExecutionResult`。监听器接收 `(exec, next)`:调用 `next()` 委托给分发(返回其结果,可选地包装),或返回替代结果以短路分发。整个流水线仍位于 `execute` 的外层 try/catch 内,因此抛出异常的监听器会变成 `isError` 结果,而非轮次失败。 -catch 是基础 `next()`(而非 waterfall 之外的东西)这一点至关重要:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为普通错误结果,然后 `timeout-policy` 才能将最终结果替换为 `TOOL_TIMEOUT`。 +catch 是基础 `next`(而非 waterfall 之外的东西)这一点至关重要:当提供方看到超时信号并抛出自己的上游中止错误时,注册表分发首先将其转换为普通错误结果,然后 `timeout-policy` 才能将最终结果替换为 `TOOL_TIMEOUT`。 ### `timeout-policy` 插件 @@ -108,4 +108,4 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { - 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 - 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 - 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。 -- 与字面提案的偏差,按 implemented-RFC 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文「决策」一节中描述。 +- 与字面提案的偏差,按 implemented-RFC 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 3253751754..fda6a0406b 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-agent-scope-contexts.md: f238d58d90413d36e81b34c1a2c94e1291e889de -2026-07-08-agent-scope-contexts.zh.md: 0f4de12e782fc72d3761b6d46cd953ab4650654d +2026-07-08-agent-scope-contexts.md: b67d51f06f74f0460f76e9fc4b45699d00b7db46 +2026-07-08-agent-scope-contexts.zh.md: e37ea60418095cb34d354150aed79a937b1b3552 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index f238d58d90..b67d51f06f 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -1,9 +1,9 @@ # RFC: The agent is a registration scope -English | [中文](2026-07-08-agent-scope-contexts.zh.md) - Status: implemented +English | [中文](2026-07-08-agent-scope-contexts.zh.md) + ## Problem One application needs to share infrastructure across many agents while letting each agent have its own tools, prompt contributions, policies, and listeners. Shared adapters, persistence, and user interfaces belong to the deployment; a persona, tool variant, or listener often belongs to one agent. diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 0f4de12e78..e37ea60418 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -1,4 +1,4 @@ -# RFC:agent 即注册作用域 +# RFC: agent 即注册作用域 Status: implemented diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 5f2ed7f3bc..c2a5189641 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-agent-scope-runtime-design.md: 5fe44f2b0a79d91ce0e32a687ed183a5ffaef284 -2026-07-12-agent-scope-runtime-design.zh.md: 6e4f09e6780858c90b0cfbcf8709eca4c79ee414 +2026-07-12-agent-scope-runtime-design.md: 1940423db9364f56ab8a13e4d636f492f97f2d54 +2026-07-12-agent-scope-runtime-design.zh.md: bbfb06db7eb886f7bc34cdb8607729fb892cf5ba diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 5fe44f2b0a..1940423db9 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -1,9 +1,9 @@ # RFC: Agent-scope runtime design and correctness -English | [中文](2026-07-12-agent-scope-runtime-design.zh.md) - Status: implemented +English | [中文](2026-07-12-agent-scope-runtime-design.zh.md) + ## Problem The [agent-scope contract](2026-07-08-agent-scope-contexts.md) is simple for contributors: register through `agent.ctx`, resolve one global-plus-agent view, publish only after setup, and retain the scope until work stops. The runtime must preserve that contract across a cooperative plugin framework, asynchronous creation, reentrant listeners, durable session commits, and worker or process failure. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 6e4f09e678..bbfb06db7e 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -1,9 +1,9 @@ -# RFC:Agent 作用域运行时设计与正确性 - -[English](2026-07-12-agent-scope-runtime-design.md) | 中文 +# RFC: Agent 作用域运行时设计与正确性 Status: implemented +[English](2026-07-12-agent-scope-runtime-design.md) | 中文 + ## 问题 [agent 作用域契约](2026-07-08-agent-scope-contexts.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份契约。 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml index b0b2ec8727..f42c8c462e 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-14-acp-agent-client-protocol.md: 0bb2a2f2e307b8f23a3a9ca98edb2a2d3b5df0a8 -2026-06-14-acp-agent-client-protocol.zh.md: 7bb0e066572150c9c8fc0b94de1d5bf2d69527ee +2026-06-14-acp-agent-client-protocol.md: 50ad2620de1dada78c4a20101b631cc5b0e9f3d1 +2026-06-14-acp-agent-client-protocol.zh.md: c4b73c00083d63e5c024953cd8c9d8dbd94d6b0a diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md index 0bb2a2f2e3..50ad2620de 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md @@ -1,9 +1,9 @@ # RFC: Agent Client Protocol (ACP) support — drive the coding agent from external editors -English | [中文](2026-06-14-acp-agent-client-protocol.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md index 7bb0e06657..c4b73c0008 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md @@ -1,9 +1,9 @@ -# RFC:Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent - -[English](2026-06-14-acp-agent-client-protocol.md) | 中文 +# RFC: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent Status: implemented +[English](2026-06-14-acp-agent-client-protocol.md) | 中文 + ## 问题 harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml index 825e4c2ed8..5a79faa4da 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-14-acp-multi-session.md: b96557d2d94711adb2183aa4f5dd8debf39c1de8 -2026-06-14-acp-multi-session.zh.md: 6a9f5e8162d46ed8719164247e22b8b9c5d26c61 +2026-06-14-acp-multi-session.md: a71f2d3d2daff3c8fae460e414d1f50facd5538f +2026-06-14-acp-multi-session.zh.md: 8b5ba46e949480d450f13aadad3ea82e0e048161 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md index b96557d2d9..a71f2d3d2d 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md @@ -1,9 +1,9 @@ # RFC: Multiplex concurrent ACP sessions over one connection -English | [中文](2026-06-14-acp-multi-session.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md index 6a9f5e8162..8b5ba46e94 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -1,9 +1,9 @@ -# RFC:在单个连接上多路复用并发 ACP 会话 - -[English](2026-06-14-acp-multi-session.md) | 中文 +# RFC: 在单个连接上多路复用并发 ACP 会话 Status: implemented +[English](2026-06-14-acp-multi-session.md) | 中文 + ## 问题 一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上保持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。 diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml b/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml index 5aeee85382..6beb8d876f 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-15-code-mode.md: c64b6d6d8442e60240fa6c849833385ed50d71ff -2026-06-15-code-mode.zh.md: e9ae74f6629f3e34a0e97f0fa532764c70095bba +2026-06-15-code-mode.md: fb95f3010121fa432b3ac24e6aa477b1205a0ba7 +2026-06-15-code-mode.zh.md: 2da6628c1842bfd507c62d4b66a91f323e6fd0e7 diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index c64b6d6d84..fb95f30101 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -1,9 +1,9 @@ # RFC: Code Mode — the model writes TypeScript against the tool registry -English | [中文](2026-06-15-code-mode.zh.md) - 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](../../../architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md index e9ae74f662..2da6628c18 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.zh.md @@ -1,9 +1,9 @@ -# RFC:Code Mode——模型针对工具注册表编写 TypeScript - -[English](2026-06-15-code-mode.md) | 中文 +# RFC: 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](../../../architecture.md) 中明确标注的 open TODO),且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 @@ -20,7 +20,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../../implemented/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 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格**更高**的环境权限执行模型编写的任意 shell 命令。 +3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 ### 注册表拥有模式 diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index f5eae41ba4..b3ed1ae8ee 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-filesystem-tool-schemas.md: c2d3aa679599b1129a19b9082b9254ecf3103f12 -2026-06-17-filesystem-tool-schemas.zh.md: cd504a37d5ee25f6634b651d30099afe5acd6495 +2026-06-17-filesystem-tool-schemas.md: dba96157a0df548e4f153a0e233238af7400c087 +2026-06-17-filesystem-tool-schemas.zh.md: 3c135de9fa81fb333abc5fa6001a7ce7d7525a9d diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index c2d3aa6795..dba96157a0 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -1,9 +1,9 @@ # RFC: Filesystem tool schemas — model-facing read/write/edit shapes -English | [中文](2026-06-17-filesystem-tool-schemas.zh.md) - Status: implemented +English | [中文](2026-06-17-filesystem-tool-schemas.zh.md) + ## Problem [The filesystem capability-seam RFC](../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) RFCs 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`. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index cd504a37d5..3c135de9fa 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -1,9 +1,9 @@ -# RFC:文件系统工具 schema——面向模型的读/写/编辑接口形状 - -[English](2026-06-17-filesystem-tool-schemas.md) | 中文 +# RFC: 文件系统工具 schema——面向模型的读/写/编辑接口形状 Status: implemented +[English](2026-06-17-filesystem-tool-schemas.md) | 中文 + ## 问题 [文件系统能力 seam RFC](../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 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFC 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 @@ -51,7 +51,7 @@ schema 使用 snake_case 字段名(`file_path`、`old_string`、`new_string` 在默认 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 复制版本令牌。 +schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面向模型的参数暴露。陈旧版本检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。 ### `edit` @@ -66,7 +66,7 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面 `edit` 要求同一执行上下文先前对该文件有过一次观测(任何窗口化的 read 都算——授权基于版本新鲜度,而非全文查看要求),或该上下文先前对该文件做过 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 负责执行。 -首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和过期版本的语义。 +首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式,使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和陈旧版本的语义。 ## 结果形状 @@ -99,14 +99,14 @@ schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒 ## 曾考虑的替代方案 -- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和过期版本的语义。 +- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和陈旧版本的语义。 - **camelCase 参数名(OpenCode 风格)**:snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开接口。 -- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。过期检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。 +- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。陈旧检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。 ## 后果 **首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非对初始 schema 的重载。 -**v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:过期检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。 +**v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之:陈旧检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。 **命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 RFC 预先选择 snake_case,并将其视为稳定的面向模型的契约。 diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml index c8ce15293a..b25f0bf18b 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-acp-terminal-and-tool-rendering.md: cab89aa690c2068399ea5429a9c467410c744ce8 -2026-06-18-acp-terminal-and-tool-rendering.zh.md: 4047c493e63ac23f758de718616fd1f4bb29f7d4 +2026-06-18-acp-terminal-and-tool-rendering.md: 9bcb65a1e0b316d0b596a80816647d46ccfca782 +2026-06-18-acp-terminal-and-tool-rendering.zh.md: 3899e3ed21b386b1652736bc3de41d338efaa30e diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md index cab89aa690..9bcb65a1e0 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md @@ -1,9 +1,9 @@ # RFC: Rich ACP bash rendering — the terminal card via the `_meta` convention -English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md) - 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](../../implemented/feature/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. diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md index 4047c493e6..3899e3ed21 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md @@ -1,9 +1,9 @@ -# RFC:富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 - -[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文 +# RFC: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 Status: implemented +[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文 + ## 问题 ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 @@ -27,7 +27,7 @@ Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 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 ` 文本内容——行为不变。 +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: …]`)来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。 ## 曾考虑的替代方案 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index aff86591fb..d99478bc53 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-compaction-capability-seam.md: 31b06905924a07a7f0c2af427d8868585966f1a7 -2026-06-18-compaction-capability-seam.zh.md: 1675484ed65e5cd890f420d4bdd1e16e2a95b2ef +2026-06-18-compaction-capability-seam.md: e88b3fd8267f65bba136398df9c439e371237912 +2026-06-18-compaction-capability-seam.zh.md: 75049079a2d85e27301e53b891228c1cbc87fac3 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 31b0690592..e88b3fd826 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -1,9 +1,9 @@ # RFC: Compaction as a capability seam (abstract contract + basic backend) -English | [中文](2026-06-18-compaction-capability-seam.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 1675484ed6..75049079a2 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -1,4 +1,4 @@ -# RFC:压缩作为能力 seam(抽象契约 + 基础后端) +# RFC: 压缩作为能力 seam(抽象契约 + 基础后端) Status: implemented @@ -66,7 +66,7 @@ request = waterfall agent/request ⟵ pure request transform (hooks, model s ### 近似收敛不变式 -`resolveConfig` 校验数值参数,但**不**基于虚构的摘要长度不变式来拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显式的推理 token 消耗,模型可能生成不可预测大小的摘要。`maxTokens` 仅是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于其遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能将保留尾部推过预算),这恰好是上述范围外的关注点,而非抖动 bug。 +`resolveConfig` 校验数值参数,但不基于虚构的摘要长度不变式来拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显式的推理 token 消耗,模型可能生成不可预测大小的摘要。`maxTokens` 仅是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于其遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能将保留尾部推过预算),这恰好是上述范围外的关注点,而非抖动 bug。 ### Surface 替换:`compact/*` 事件仅存在于日志;一条 `user/message` 承载摘要 diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 1da7113f51..20172e4369 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-21-subagent-capability-seam.md: 2bed84cd9166e8aa1ad5fa65b3afa44b8a842045 -2026-06-21-subagent-capability-seam.zh.md: a5917c14141dd06c14b4f45c5f6e4703f0eb661f +2026-06-21-subagent-capability-seam.md: ff8ca7d6292055715d94b3878860bffafb8a8057 +2026-06-21-subagent-capability-seam.zh.md: c11623531c753cd454621f450f33cff5edbbf815 diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 2bed84cd91..ff8ca7d629 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -1,9 +1,9 @@ # RFC: Subagent capability seam -English | [中文](2026-06-21-subagent-capability-seam.zh.md) - Status: implemented +English | [中文](2026-06-21-subagent-capability-seam.zh.md) + > The full seam is shipped: the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `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 RFC](2026-06-22-acp-subagent-backend.md)). ## Problem diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index a5917c1414..c11623531c 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -1,9 +1,9 @@ -# RFC:Subagent 能力 seam - -[English](2026-06-21-subagent-capability-seam.md) | 中文 +# RFC: Subagent 能力 seam Status: implemented +[English](2026-06-21-subagent-capability-seam.md) | 中文 + > 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 RFC](2026-06-22-acp-subagent-backend.md))。 ## 问题 @@ -43,7 +43,7 @@ bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-s ### 两类可选能力,两种发现方式 -- **启动时特性**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派**之前**检查每个被请求的特性,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些特性必须在 run 存在之前检查,因此不能是运行时方法。 +- **启动时特性**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的特性,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些特性必须在 run 存在之前检查,因此不能是运行时方法。 - **运行时特性**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。 ### Fork 与 fresh 是独立后端,而非一个 flag diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 2939af3939..99c3a2b5e9 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-acp-subagent-backend.md: 7eb03ddf68f54c29524944e7b8bc801eb1724fe6 -2026-06-22-acp-subagent-backend.zh.md: 249f5a5ebf18d42f3d83d2159f6c2bcb52a245c3 +2026-06-22-acp-subagent-backend.md: c02b027177ae96d75ff0d3dcac227145fe71b340 +2026-06-22-acp-subagent-backend.zh.md: 2b4db4e20fd872d21ffb72b1c2a4432c6f0103a9 diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index 7eb03ddf68..c02b027177 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -1,9 +1,9 @@ # RFC: ACP subagent backend (out-of-process delegation) -English | [中文](2026-06-22-acp-subagent-backend.zh.md) - Status: implemented +English | [中文](2026-06-22-acp-subagent-backend.zh.md) + ## Problem The subagent seam ([the seam RFC](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 RFC adds the first such backend: an Agent Client Protocol (ACP) client. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 249f5a5ebf..2b4db4e20f 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -1,16 +1,16 @@ -# RFC:ACP subagent 后端(进程外委派) - -[English](2026-06-22-acp-subagent-backend.md) | 中文 +# RFC: ACP subagent 后端(进程外委派) Status: implemented +[English](2026-06-22-acp-subagent-backend.md) | 中文 + ## 问题 -subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在**同一个** Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的**进程外**子 agent,以证明该抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。 +subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在同一个 Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的进程外子 agent,以证明该抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent 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 与自身进程通信。 +`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个派生的子进程中,并以 ACP *客户端*身份驱动它。它是现有服务端桥接 `@deepseek-ai/dsh-acp`(ACP *agent*)的方向反转孪生体:桥接应答 `initialize`/`newSession`/`prompt`;本后端调用它们并实现 `Client` 回调(`sessionUpdate`、`requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程通信。 ### 每次运行启动全新进程 @@ -30,7 +30,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ### 安全:清洗子进程环境 -子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认**不**转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent **自己**的凭证(它需要模型密钥)通过 `config.env` **显式**提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。 +子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。 ## 测试 diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml index 30f57427e2..1feca9e724 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-25-ask-user-question.md: 06673233038d10214f8de3d1f29766d43b575442 -2026-06-25-ask-user-question.zh.md: a036220fd54e3f634ab4be80a45964b076d3fd2d +2026-06-25-ask-user-question.md: e28d34e7b13eb8920db1eb77f4cc66aec58c3525 +2026-06-25-ask-user-question.zh.md: 86194b4eeca2761b0af412f1510a688ef48b0422 diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md index 0667323303..e28d34e7b1 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -1,9 +1,9 @@ # RFC: Ask-user question capability -English | [中文](2026-06-25-ask-user-question.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md index a036220fd5..86194b4eec 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.zh.md @@ -1,9 +1,9 @@ -# RFC:ask-user 提问能力 - -[English](2026-06-25-ask-user-question.md) | 中文 +# RFC: ask-user 提问能力 Status: implemented +[English](2026-06-25-ask-user-question.md) | 中文 + ## 问题 agent(智能体)有时仅凭模型推理(inference)无法安全地继续执行:它需要人类选择路径、确认有风险的或默认的操作,或者提供缺失的信息。在此变更之前,获取答案的唯一方式是模型在 assistant 文本中提问然后停止,这打断了正常的工具调用循环:agent 没有结构化的暂停方式,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的途径。 diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index 677df03ea6..cacdff7448 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-29-todo-write-tool.md: 69f81cf6fd93df63ce53bb82c97dbac16dbbd486 -2026-06-29-todo-write-tool.zh.md: eb3c6fb8a9ddc7d26e4a620761c96a8d35ddf469 +2026-06-29-todo-write-tool.md: 7147440ec0cdb0b53093c371828d1987c17aabb0 +2026-06-29-todo-write-tool.zh.md: 7602ac8434963c84f089fe99a6f1e973c05e5bef diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md index 69f81cf6fd..7147440ec0 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md @@ -1,9 +1,9 @@ # RFC: The `todo_write` tool — model task list as event-sourced session state -English | [中文](2026-06-29-todo-write-tool.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md index eb3c6fb8a9..7602ac8434 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -1,4 +1,4 @@ -# RFC:`todo_write` 工具——将模型任务列表作为事件溯源的会话状态 +# RFC: `todo_write` 工具——将模型任务列表作为事件溯源的会话状态 Status: implemented @@ -14,7 +14,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ### 整列表替换,三态 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 映射,无需有损转换。 +模型每次调用发送完整列表;新列表替换旧列表(回放时 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 映射,无需有损转换。 ### 状态在会话日志上,而非服务 @@ -38,7 +38,7 @@ claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2) ### 校验:低成本的中间路线 -schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content`、重复 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给 prompt 约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。 +schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重复的 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给 prompt 约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。 ## 为何没有 cordis-catalog 条目 / 没有 `@mode` @@ -48,7 +48,7 @@ schema 强制 type/required/enum。在此之上,`execute` 拒绝空 `content` 四个层级,预先设计: - **单元测试**——会话事件(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)。 +- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。 - **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write`;`todo/write` 事件落地,第二次调用替换它。 - **`session/load` 回放**——持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。 - **带密钥 e2e + 快照**——真实 prompt 诱导一次 `todo_write`;快照 golden 获得 `plan` 通知和日志事件。 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 03e276e446..fe4a29a063 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-hook-bridges.md: 17ff57307c34121c845592efa93c723e66c98886 -2026-06-30-hook-bridges.zh.md: a4b8c12593cdac35deb882ba15a58876650c1653 +2026-06-30-hook-bridges.md: c3b268bb4062c3e31acc6cdfc7641b0d1a24c47a +2026-06-30-hook-bridges.zh.md: 57ccfae2f75858254352838049516aca3668d083 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index 17ff57307c..c3b268bb40 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -1,9 +1,9 @@ # RFC: dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges -English | [中文](2026-06-30-hook-bridges.zh.md) - Status: implemented +English | [中文](2026-06-30-hook-bridges.zh.md) + ## Problem The harness's extension surface is its typed interception seams ([the interception-seams RFC](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 RFC 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 RFC](2026-06-30-hook-protocol-lib.md)). diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md index a4b8c12593..57ccfae2f7 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -1,4 +1,4 @@ -# RFC:dsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件 +# RFC: dsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件 Status: implemented @@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](2026-06 `packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../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`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。CC 钩子的 stdin 带有**尾部换行**。 -- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时**不带**尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 +- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 ### Outcome → Decision 映射 diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 5979186c01..f745911340 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-hook-protocol-lib.md: 924c320f7ef9fdb55b20ff06f492addbf42d1720 -2026-06-30-hook-protocol-lib.zh.md: 81315cbe8767e9a3cc07cdef92359734e4e20f31 +2026-06-30-hook-protocol-lib.md: bd00c35ee2a1a1075ecae936af21508004f606b8 +2026-06-30-hook-protocol-lib.zh.md: 59b473e2a4e6f2dd95fa7bb7046848b036e5466a diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md index 924c320f7e..bd00c35ee2 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -1,9 +1,9 @@ # RFC: dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core -English | [中文](2026-06-30-hook-protocol-lib.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 81315cbe87..59b473e2a4 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -1,9 +1,9 @@ -# RFC:dsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库 - -[English](2026-06-30-hook-protocol-lib.md) | 中文 +# RFC: dsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库 Status: implemented +[English](2026-06-30-hook-protocol-lib.md) | 中文 + ## 问题 hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Code(CC)钩子,另一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型。Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了"有意偏离"之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑。 @@ -16,7 +16,7 @@ hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Cod **共享(本库):** - **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。 -- **Execution** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 spawn 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 +- **Execution** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **Decode** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **Merge** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block reason 以 `\n\n` 拼接,context/system-messages 按序累积。 - **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,declaration-merge 进 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与 turn 包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml index 2a1efb261c..1079a59599 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-interception-seams.md: fb8efe1e1c2057db13b440881f110ca7f579a81e -2026-06-30-interception-seams.zh.md: 668b96dba282ecdcbe85cc0b1dc56c2de293b3b3 +2026-06-30-interception-seams.md: 66e1c2374936c794bd5f791547c283f0fb59fb23 +2026-06-30-interception-seams.zh.md: fb2f1528a8e72a2f6b8b07679c8631b1b0a4f2d8 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index fb8efe1e1c..66e1c23749 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -1,9 +1,9 @@ # RFC: Interception seams — the typed-Decision surface a hook programs against -English | [中文](2026-06-30-interception-seams.zh.md) - 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). diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md index 668b96dba2..fb2f1528a8 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.zh.md @@ -1,4 +1,4 @@ -# RFC:拦截 seam——钩子编程所面对的类型化 Decision 表面 +# RFC: 拦截 seam——钩子编程所面对的类型化 Decision 表面 Status: implemented @@ -15,7 +15,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 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/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 - `agent/prompt-submit(agent, content, source, next) → PromptDecision` ——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。 **`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。 @@ -38,7 +38,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 1. **在 prompt 策略之前开启轮次。** 全部被阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。 -2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条**独立的** `context/message`,而单个步骤可以携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤的每次调用缓冲上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。 +2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条独立的 `context/message`,而单个步骤可以携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤的每次调用缓冲上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。 3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的 prompt(与现有的 `hasSteering` 强制继续覆盖一致)。 diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index c05a025ca6..b06ccc29d9 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-session-store-fork-api.md: 4bf5c3fe43821570fd947034358e54d0a0a602f9 -2026-06-30-session-store-fork-api.zh.md: a3ffb881a446647861fa5fbaf57dde291838a090 +2026-06-30-session-store-fork-api.md: c5359a30124aabf0f9f809ffe899c6ad5fca8051 +2026-06-30-session-store-fork-api.zh.md: 62f399df41488143a66069fdd5a36ddb3e62421f diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md index 4bf5c3fe43..c5359a3012 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -1,9 +1,9 @@ # RFC: SessionStore fork API -English | [中文](2026-06-30-session-store-fork-api.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md index a3ffb881a4..62f399df41 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -1,9 +1,9 @@ -# RFC:SessionStore fork API - -[English](2026-06-30-session-store-fork-api.md) | 中文 +# RFC: SessionStore fork API Status: implemented +[English](2026-06-30-session-store-fork-api.md) | 中文 + ## 问题 事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。 diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml index 90b6939da2..cb9c05d033 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-subagent-observe-enrich.md: b48a1fff32130345e669a3b3b905c4fda987e41e -2026-06-30-subagent-observe-enrich.zh.md: 8ce070001e219572658fd4e94c660de1094ddee5 +2026-06-30-subagent-observe-enrich.md: 1e20ddff5473a23f3ed560246fbd06f8090852ae +2026-06-30-subagent-observe-enrich.zh.md: 6bc00c9a8f1d3656fbbfc482e3e6b66020298dd9 diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md index b48a1fff32..1e20ddff54 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -1,9 +1,9 @@ # RFC: Subagent lifecycle enrichment — lastAssistantMessage (observe-only) -English | [中文](2026-06-30-subagent-observe-enrich.zh.md) - Status: implemented +English | [中文](2026-06-30-subagent-observe-enrich.zh.md) + ## Problem The hooks subsystem ([interception seams RFC](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. diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md index 8ce070001e..6bc00c9a8f 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md @@ -1,4 +1,4 @@ -# RFC:Subagent 生命周期丰富化——lastAssistantMessage(仅观察) +# RFC: Subagent 生命周期丰富化——lastAssistantMessage(仅观察) Status: implemented diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index 25c723bde1..bcf9d40b91 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-dynamic-workflows.md: 67ceebf7017f197bd800fd339b575390b3b936c1 -2026-07-05-dynamic-workflows.zh.md: 54ba0d903de228e14a53e6f64ead0f5156e61289 +2026-07-05-dynamic-workflows.md: e6f2ab9a4fc5f5403a7739495be7d82ebce3ec0d +2026-07-05-dynamic-workflows.zh.md: 82234c2405f972f2c42c58be6772f55eda500455 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 67ceebf701..e6f2ab9a4f 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -1,9 +1,9 @@ # RFC: Dynamic workflows — a script-driven multi-agent orchestration seam -English | [中文](2026-07-05-dynamic-workflows.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 54ba0d903d..82234c2405 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -1,9 +1,9 @@ -# RFC:动态工作流——脚本驱动的多 agent 编排 seam - -[English](2026-07-05-dynamic-workflows.md) | 中文 +# RFC: 动态工作流——脚本驱动的多 agent 编排 seam Status: implemented +[English](2026-07-05-dynamic-workflows.md) | 中文 + ## 问题 harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立部分的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划无处持久存储,每一步的协调都要消耗一次模型往返。Claude Code 以 [dynamic workflows](https://code.claude.com/docs/en/workflows) 的形式提供了这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。 @@ -16,11 +16,11 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) 一次工作流调用包含 JSON `meta`(`name`、`description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被执行。正文接收 `agent(prompt, options)`、`parallel(thunks)`、`pipeline(items, ...stages)`、`phase(title)`、`log(message)` 和 `args`。pipeline 各阶段接收 `(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)保持诚实。 +与 CC 有一处刻意的严格性差异:钩子误用——未知或延迟的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——会抛出带 `fatal: true` 的 `WorkflowError`,组合器会重新抛出 fatal 错误而非将 item 置为 null。如果不这样做,一个拼错的选项会悄然变成一个与子 agent 失败无法区分的 `null`——这正是本仓库禁止的「被接受后被忽略」的失败模式。另有一处新增:工具的 `args` 参数是一个 JSON 对象(裸列表被包装为一个字段),使协议格式(wire format)保持诚实。 ### seam(dsh-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](../../../core-data-structures/workflow.md)。 +`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](../../../core-data-structures/workflow.md)。 ### 引擎(dsh-workflow-workerthread):每次运行一个 worker 线程 @@ -38,7 +38,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) ### 消费方(dsh-tool-workflow) -一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述**即**面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` prompt 段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。 +一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` prompt 段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。 ### 基础:subagent seam 上的结构化输出 diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml b/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml index 3b3aed7c50..31fb9e006f 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-skill-system.md: 6cfd1f977ae5a1e1ad646a707a4201e57d46bc38 -2026-07-05-skill-system.zh.md: f59fd5d850a38d7324b391a116c72c4e71ef7401 +2026-07-05-skill-system.md: f39b5f5766eadf7c0a7bfd3f887aca60f69477af +2026-07-05-skill-system.zh.md: 0aff262792a6f8a38a02475d6975510d5aae5da5 diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.md index 6cfd1f977a..f39b5f5766 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.md +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.md @@ -1,9 +1,9 @@ # RFC: Skill system — progressive disclosure instructions for agents -English | [中文](2026-07-05-skill-system.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md index f59fd5d850..0aff262792 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.zh.md @@ -1,9 +1,9 @@ -# RFC:Skill 系统——面向 agent 的渐进式指令披露 - -[English](2026-07-05-skill-system.md) | 中文 +# RFC: Skill 系统——面向 agent 的渐进式指令披露 Status: implemented +[English](2026-07-05-skill-system.md) | 中文 + ## 问题 Agent(智能体)产品已趋同于一种 skill(技能)模式:保持请求提示词精简,仅列出可用的指令包,当模型判定某任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 与 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用的行为而无需在每个轮次支付全量提示词开销。 diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml index 8d7fa05dfc..fe71c00352 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-approval-seam.md: 3ef51c31216bf9f0c5d945748ab3f901ec82147c -2026-07-06-approval-seam.zh.md: 1bf679426a434c36f5363c3b70f13a8f24534df3 +2026-07-06-approval-seam.md: dedcc2022382af7a614023e4354b77f1c15cd92f +2026-07-06-approval-seam.zh.md: 193461a2e5c3c14efe4b1565656cc38baab11fcb diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 3ef51c3121..dedcc20223 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -1,9 +1,9 @@ # RFC: The approval seam — one-shot permission decisions over a waterfall of answerers -English | [中文](2026-07-06-approval-seam.zh.md) - 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 RFC](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. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md index 1bf679426a..193461a2e5 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.zh.md @@ -1,4 +1,4 @@ -# RFC:审批 seam——基于 waterfall(瀑布式事件)应答者的一次性权限决策 +# RFC: 审批 seam——基于 waterfall(瀑布式事件)应答者的一次性权限决策 Status: implemented @@ -12,7 +12,7 @@ Status: implemented ## 决策 -一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即**机制**。**策略**——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为**一个**包,而非能力 seam 的三包拆分(见「替代方案」)。 +一个包 `dsh-user-approval`(`packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制。策略——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为一个包,而非能力 seam 的三包拆分(见「替代方案」)。 ### 部署如何使用它 @@ -95,7 +95,7 @@ ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/reque ## 曾考虑的替代方案 - **单一注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——允许列表预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时失败关闭和 HMR(热模块替换) dispose(资源释放);seam 的 JSDoc 以约定固定单决策槽语义,而非发明一个提供方注册表。 -- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会将请求**策略**硬编码进 UI 插件,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时刻),且钩子产生的 `ask` 决策没有共享机制。 +- **在 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 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seam(bash-local vs bash-sandbox)。此处服务体是固定机制,可变部分是留在各自通道拥有者插件中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。 diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml index 0e418c8e57..53215fce5e 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-explicit-tool-order.md: 9d94496ffdcbc4c7df820581b02e3e075ec1c0be -2026-07-06-explicit-tool-order.zh.md: 0b020f969299799289e18ed93c81db08cabc0b2d +2026-07-06-explicit-tool-order.md: b5f37239efc856866f08d93ab80eba91145a34db +2026-07-06-explicit-tool-order.zh.md: eca75499c2505c9cac0135ddb2ffc8cdd989b100 diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 9d94496ffd..b5f37239ef 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -1,9 +1,9 @@ # RFC: Explicit model-facing tool order -English | [中文](2026-07-06-explicit-tool-order.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md index 0b020f9692..eca75499c2 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md @@ -1,9 +1,9 @@ -# RFC:显式的模型侧工具顺序 - -[English](2026-07-06-explicit-tool-order.md) | 中文 +# RFC: 显式的模型侧工具顺序 Status: implemented +[English](2026-07-06-explicit-tool-order.md) | 中文 + ## 问题 模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于相互独立的插件的并发模块加载。这种竞态在 CI 和快照录制中产生了不同的请求头。由于顺序影响请求字节、缓存和持久化的 header,因此需要一个显式的确定性策略。 diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml b/docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml index 8c97f0b5ee..87137ea20f 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-sandbox.md: 29985b9336e7a190c90dc0e9c3b3aa78b6197e8e -2026-07-06-sandbox.zh.md: 4285ae4ceb81ee57dc3ab3d51467f9267743e06e +2026-07-06-sandbox.md: 0df97717c9166d5160189c840b108acecd3d3291 +2026-07-06-sandbox.zh.md: 164e44f6b7c84d8f279b0146cc3c74299a51d1fa diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index 29985b9336..0df97717c9 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -1,9 +1,9 @@ # RFC: The subprocess sandbox — confinement seam, native runners, escalation, and per-session modes -English | [中文](2026-07-06-sandbox.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md index 4285ae4ceb..164e44f6b7 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.zh.md @@ -1,4 +1,4 @@ -# RFC:子进程沙箱——约束 seam、原生 runner、升级机制与按会话模式 +# RFC: 子进程沙箱——约束 seam、原生 runner、升级机制与按会话模式 Status: implemented @@ -8,7 +8,7 @@ Status: implemented 一个编码 agent 需要如下产品路径:bash 子进程(以及依附其上的钩子命令)默认在受限的文件沙箱下执行;当且仅当沙箱实际拒绝了某个操作时,模型可以为同一操作请求一次用户批准,获批后以更宽的权限重试一次。本设计刻意不声称覆盖所有工具:fs/web/todo 在进程内执行,`execve` 包装对它们毫无意义(§ 进程内工具);跨工具族的统一边界属于分阶段后续工作(§ 延迟阶段)。如果没有共享词汇,每个工具都会各自重新发明批准字段、拒绝解析、重试匹配和权限状态提示。 -harness 是一个 SDK,因此约束必须是开发者可**组合**的能力:是否启用沙箱、每个平台使用哪个后端,都应作为一等条目写在叶子 `cordis.yml` 中,而非藏在某个执行器的私有机制里。而首选 runner `bwrap` 恰恰在沙箱最重要的主机上不可用(精简容器、禁用了非特权 userns、LSM 拒绝 `mount`),因此备选 runner 必须随 SDK 一起交付,而不能假设主机已有。 +harness 是一个 SDK,因此约束必须是开发者可组合的能力:是否启用沙箱、每个平台使用哪个后端,都应作为一等条目写在叶子 `cordis.yml` 中,而非藏在某个执行器的私有机制里。而首选 runner `bwrap` 恰恰在沙箱最重要的主机上不可用(精简容器、禁用了非特权 userns、LSM 拒绝 `mount`),因此备选 runner 必须随 SDK 一起交付,而不能假设主机已有。 仅有约束还留下两个缺口。拒绝后没有升级路径就是死路:模型只能放弃,这会迫使运维人员全局配置 `workspace-write` 或 `danger-full-access`,从而使沙箱形同虚设。而模型可见的旋钮(沙箱模式、批准策略)在 agent 生命周期内会变化——ACP 用户切换按会话设置、运维人员在进程停止期间编辑 `cordis.yml`——模型绝不能基于过时的信念行动:每次请求时的实际状态是什么、agent 存活期间发生了什么变化、无人看管时又发生了什么变化,都需要有明确答案。 @@ -50,11 +50,11 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 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`)、`SandboxPolicy`(mode + workspace root)。 +`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`)、`SandboxPolicy`(mode + workspace root)。 -策略随每次**调用**而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 +策略随每次调用而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 -该 seam 仅约束**同世界**子进程:后端共享主机的文件系统和内核。容器、microVM 和远程执行器不是此 seam 的后端——它们以环境一致的组替换整个能力实现(`ctx.bash`、`ctx.fs`),因为一个 bash 在容器中运行而 fs 工具写主机的 agent 生活在两个割裂的世界中。 +该 seam 仅约束同世界子进程:后端共享主机的文件系统和内核。容器、microVM 和远程执行器不是此 seam 的后端——它们以环境一致的组替换整个能力实现(`ctx.bash`、`ctx.fs`),因为一个 bash 在容器中运行而 fs 工具写主机的 agent 生活在两个割裂的世界中。 留待需要时再决定:网络限制是作为独立的 `network_mode` 到来,还是在某个 runner 同时强制两者后合并进 `sandbox_mode`;以及 `SandboxPolicy` 是现在就增加额外的可写根授权(launcher 已支持 `--rw <path>`),还是等到升级机制需要时再加。 @@ -80,7 +80,7 @@ FIXME: Revisit the separate-repository boundary and try to maintain the launcher `BashExecRequest.sandboxMode` 是可选的按调用输入;解析后的 spec 使该字段显式。`BashExecutor.sandboxMode` 公布已挂载的执行器能否兑现它,因此只有约束组合才暴露升级。seam 接受任何显式模式;工具拥有「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。 -`SandboxBashExecutor.resolve()` 盖章有效模式——升级授权 > 会话覆盖 > 配置默认——使 `run()`/`start()` 读取 spec 而非配置。`danger-full-access` 分支、confine 调用和结果事实都以 spec 的模式为键,且按任务的事实 map 携带每个任务的模式及其包装事实(`notifyTaskDone()` 从 map 条目盖章):一次升级调用——前台或后台——报告它**实际**运行的模式,而每个邻居保持自己的。 +`SandboxBashExecutor.resolve()` 盖章有效模式——升级授权 > 会话覆盖 > 配置默认——使 `run()`/`start()` 读取 spec 而非配置。`danger-full-access` 分支、confine 调用和结果事实都以 spec 的模式为键,且按任务的事实 map 携带每个任务的模式及其包装事实(`notifyTaskDone()` 从 map 条目盖章):一次升级调用——前台或后台——报告它实际运行的模式,而每个邻居保持自己的。 当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。 @@ -94,7 +94,7 @@ FIXME: Revisit the separate-repository boundary and try to maintain the launcher effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是**会话范围**的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离(一个编辑器标签页的 `workspace-write` 不会干扰另一个的 `read-only`)都是构造性的自然结果,且不存在任何外部配置存储。 +默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离(一个编辑器标签页的 `workspace-write` 不会干扰另一个的 `read-only`)都是构造性的自然结果,且不存在任何外部配置存储。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): @@ -105,13 +105,13 @@ interface SessionEventMap { } ``` -每个拥有者导出相同的三件套:事件声明、纯 fold(`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及**唯一的**写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无共享拥有者服务、无通用 facts map、无注册表:第三个旋钮只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 RFC](2026-07-06-approval-seam.md) 同一模式的另一侧。 +每个拥有者导出相同的三件套:事件声明、纯 fold(`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无共享拥有者服务、无通用 facts map、无注册表:第三个旋钮只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 RFC](2026-07-06-approval-seam.md) 同一模式的另一侧。 沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'never'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个 pre-step 递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的。 **编辑器界面**是协议原生的 [Session Config Options](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 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom`。`session/set_config_option` 通过 permission 服务验证并切换,然后返回完整的刷新状态(规范契约)。 -**轮次封闭是提交边界。**开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定,在下一次 prompt 提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`,setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。 +**轮次封闭是提交边界。** 开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定,在下一次 prompt 提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`,setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。 #### 进程内工具 @@ -121,10 +121,10 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine ### 测试 -- **单元测试:**固定平台选择和 profile、失败关闭的 runner 分类、按调用事实、升级验证和结果、permission preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。 -- **Keyless 真实 runner:**在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定 permission 切换并拒绝未知 preset。CI 拒绝静默全跳过。 -- **With-key:**驱动真实模型、runner、bridge 应答器和磁盘效果通过授权和拒绝的升级;不可用的凭证或 runner 自动跳过。 -- **快照:**固定 permission config-option 协议格式(wire format)、preset 和旋钮事件、prompt delta 和通知、以及两个脚本化的 approval 分支。快照模式以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。真实拒绝 stderr 留在平台测试中,因为其方言是 runner 特定的。 +- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用事实、升级验证和结果、permission preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。 +- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定 permission 切换并拒绝未知 preset。CI 拒绝静默全跳过。 +- **With-key:** 驱动真实模型、runner、bridge 应答器和磁盘效果通过授权和拒绝的升级;不可用的凭证或 runner 自动跳过。 +- **快照:** 固定 permission config-option 协议格式(wire format)、preset 和旋钮事件、prompt delta 和通知、以及两个脚本化的 approval 分支。快照模式以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。真实拒绝 stderr 留在平台测试中,因为其方言是 runner 特定的。 ## 延迟阶段 @@ -149,22 +149,22 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine - **一个接口同时覆盖容器/VM**:否决。`confine(argv)` 预设共享文件系统;环境隔离是作为一致组部署的能力兄弟后端。 - **通用 ToolRuntime 包装任何工具**:否决。对进程内工具(闭包了 `ctx`)机械上不成立;声明式效果重写对 fs/web/todo 而言不合理。 - **在执行器内部(`dsh-bash-sandbox`)请求批准**:否决。没有可路由的 `agent`,没有可附加 prompt 的 `callId`;添加它们会让传输 seam 了解会话和 UI——工具层持有两者并拥有面向模型的词汇。 -- **同一工具调用内自动重试**:否决。日志无法重建的隐藏重入:一个 `tool/call` 会产生两次具有不同策略的执行——重试是一次**新的**带有自身参数和结果事实的已记录调用。 +- **同一工具调用内自动重试**:否决。日志无法重建的隐藏重入:一个 `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 和 sandbox 独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 - **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/prompt-submit`),而 pre-step 的位置以一个监听器同时服务合并的轮次入口通知和轮中即时性约束。 -- **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝**尝试**被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。 -- **用专门的簿记事件追踪「上次告知」**:否决。`request/header*` fold 已记录模型看到的确切 prompt;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们**本身即为存储**时才需要。 +- **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。 +- **用专门的簿记事件追踪「上次告知」**:否决。`request/header*` fold 已记录模型看到的确切 prompt;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **ACP session modes 而非 config options**:否决。preset 已经是一个部署定义的 config-option 选择器,且 modes 计划在 ACP v2 中移除。 ## 后果 已交付并固定的内容——测试中的各层级分别保障: -- 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使**该次**调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。 +- 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。 - 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。 - 系统提示词从不声明沙箱模式(批准 `'never'` 策略是唯一被声明的旋钮),且整个交互——header、旋钮事件、通知、批准、结果——仅从会话日志即可重建,除两个旋钮事件外无额外事件类型。 - N 次空闲切换每个旋钮最多产生一个锚定事件(净零序列不锚定任何事件——客户端回显当前选择的无操作推送不记录任何内容);批准策略切换最多以一条合并通知叙述;轮中沙箱切换由下一次调用的盖章兑现。 @@ -175,31 +175,31 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine 代价与已接受的限制: - **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加 prompt 约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 -- **`read-only` 尚不是跨工具族边界。**在 fs 意图门控按共享模式决策之前,该声明仅对 bash 成立;契约诚实地如此声明(§ 进程内工具)。 -- **Windows 没有后端。**其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 -- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。**作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。 -- **Landlock 约束的完整度取决于运行内核的 ABI。**报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 -- **launcher 作为注册表依赖到达。**通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 -- **模型可能过度请求。**在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的 prompt 是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 +- **`read-only` 尚不是跨工具族边界。** 在 fs 意图门控按共享模式决策之前,该声明仅对 bash 成立;契约诚实地如此声明(§ 进程内工具)。 +- **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 +- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。 +- **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 +- **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。 +- **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的 prompt 是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 -- **授权的升级不等于可工作的沙箱。**不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 -- **空闲切换存在于 bridge 内存中,直到下一次 prompt 提交锚定它。**该窗口内的崩溃将其回退(在 `session/load` 时报告),且永不再提交 prompt 的会话永不持久化它——已接受,loop 拥有的空闲提交轮次留作未来工作(如果持久性成为需求)。 -- **批准叙述器的重启基线解析 prompt 文本。**封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑;header 早于该段落的会话静默采用当前策略而不发通知。 +- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 +- **空闲切换存在于 bridge 内存中,直到下一次 prompt 提交锚定它。** 该窗口内的崩溃将其回退(在 `session/load` 时报告),且永不再提交 prompt 的会话永不持久化它——已接受,loop 拥有的空闲提交轮次留作未来工作(如果持久性成为需求)。 +- **批准叙述器的重启基线解析 prompt 文本。** 封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑;header 早于该段落的会话静默采用当前策略而不发通知。 - **批准段落仍是动态 prompt 表面**(`'never'` 切换会破坏该会话的提供方 prompt 前缀缓存)。已接受:策略切换罕见,且模型基于过时的 `'never'` 行动更糟。沙箱旋钮不再触及 prompt。 - **模型可能持有关于沙箱模式的过时信念**(没有任何东西宣布切换)。有意接受:下一次尝试的标记或成功会纠正它,而宣布的观察到的失败模式——预防性拒绝——比一次浪费的重试更糟。 ## 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 工具,以及传递性的钩子命令。fs/web/todo 在进程内执行,`execve` 包装对它们机械上毫无意义;它们的 `read-only` 语义随跨工具族延迟阶段到来,在此之前契约诚实地声明仅限 bash。 -- **授权的升级会持久化吗?或覆盖后台任务吗?**都不会:授权被请求它的那次调用(前台或后台)消耗,该次调用报告它实际运行的模式,而每个邻居保持自己的。如何为通过 `bash_output` 延迟浮现的后台拒绝**定义**升级,留在 § 升级机制中开放。 -- **编辑器的模式切换何时生效?**轮中:立即追加,由下一次调用的盖章兑现。空闲:保持在 bridge 的会话记录上,在下一次 `agent/prompt-submit` 时锚定到其开放轮次中,N 次切换合并为最多一个事件(净零则无);锚定前崩溃回退它,`session/load` 报告真实状态。模型不被告知——其下一个命令直接在新模式下运行。 -- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?**覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值以与切换相同的方式改变行为(批准策略因被声明,还额外以运维人员/配置归因叙述)。 -- **结果上的 `enforcement: 'partial'` 是什么意思?**所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`。 +- **一个命令返回了 `[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 工具,以及传递性的钩子命令。fs/web/todo 在进程内执行,`execve` 包装对它们机械上毫无意义;它们的 `read-only` 语义随跨工具族延迟阶段到来,在此之前契约诚实地声明仅限 bash。 +- **授权的升级会持久化吗?或覆盖后台任务吗?** 都不会:授权被请求它的那次调用(前台或后台)消耗,该次调用报告它实际运行的模式,而每个邻居保持自己的。如何为通过 `bash_output` 延迟浮现的后台拒绝定义升级,留在 § 升级机制中开放。 +- **编辑器的模式切换何时生效?** 轮中:立即追加,由下一次调用的盖章兑现。空闲:保持在 bridge 的会话记录上,在下一次 `agent/prompt-submit` 时锚定到其开放轮次中,N 次切换合并为最多一个事件(净零则无);锚定前崩溃回退它,`session/load` 报告真实状态。模型不被告知——其下一个命令直接在新模式下运行。 +- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值以与切换相同的方式改变行为(批准策略因被声明,还额外以运维人员/配置归因叙述)。 +- **结果上的 `enforcement: 'partial'` 是什么意思?** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`。 ## 先例 diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 03a68c9166..360ea648dc 100644 --- a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-mcp-client-plugin.md: 7706190257b54730532e4aa46cc9c47453c59871 -2026-07-07-mcp-client-plugin.zh.md: b5fee7eff7f12de5658f0a10c32cfeed71482fdf +2026-07-07-mcp-client-plugin.md: 99159d6ac31fb8ef74b28a7048392d0e22124b5a +2026-07-07-mcp-client-plugin.zh.md: e4c61f173956a928c4030fc53796324b924f32d9 diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md index 7706190257..99159d6ac3 100644 --- a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -1,9 +1,9 @@ # RFC: MCP client plugin — connect to external MCP servers and bridge their tools -English | [中文](2026-07-07-mcp-client-plugin.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index b5fee7eff7..e4c61f1739 100644 --- a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -1,4 +1,4 @@ -# RFC:MCP 客户端插件——连接外部 MCP 服务器并桥接其工具 +# RFC: MCP 客户端插件——连接外部 MCP 服务器并桥接其工具 Status: implemented @@ -54,7 +54,7 @@ interface StreamableHttpConfig { type Config = StdioConfig | StreamableHttpConfig ``` -`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具提供命名空间。它有意设计为用户配置,而**非**远端的 `serverInfo.name`:远端名称是不可信输入、跨部署不唯一(同一服务器的生产和预发布实例报告相同名称)、且可能在服务器升级时变化——这些都不得静默重命名模型可见工具。多个活跃实例使用重复的 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)也是缩短公开名称的调节手段。 +`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具提供命名空间。它有意设计为用户配置,而非远端的 `serverInfo.name`:远端名称是不可信输入、跨部署不唯一(同一服务器的生产和预发布实例报告相同名称)、且可能在服务器升级时变化——这些都不得静默重命名模型可见工具。多个活跃实例使用重复的 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)也是缩短公开名称的调节手段。 `cordis.yml` 用法示例: diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml index 038349fd78..02d6f23514 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-session-prefix.md: ffa0fecb86e84ed64d45b24e1b6943d421757fb2 -2026-07-07-session-prefix.zh.md: ca38ebf337e16f4f2368ca74e394fcc1031fbf1f +2026-07-07-session-prefix.md: 81868c5b0c17c03aa1ebad788c8604dd30d1ce17 +2026-07-07-session-prefix.zh.md: 7d93d8190ce51fcbdc1b67e20fbdb4f4ea8e14c8 diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index ffa0fecb86..81868c5b0c 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -1,9 +1,9 @@ # RFC: The session prefix — request-only messages in front of the derived history -English | [中文](2026-07-07-session-prefix.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md index ca38ebf337..7d93d8190c 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.zh.md @@ -1,4 +1,4 @@ -# RFC:会话前缀——派生历史之前的仅请求消息 +# RFC: 会话前缀——派生历史之前的仅请求消息 Status: implemented @@ -12,15 +12,15 @@ Status: implemented ## 决策 -`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 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 +`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 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 三个属性承载了这一设计: -- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 RFC 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + 边界派生`;未记录的前缀无法到达协议格式。 +- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 RFC 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + boundary derivation`;未记录的前缀无法到达协议格式。 - **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 产生新实例:它重新组合,任何漂移都可追溯地落在 `'resume'` header 快照上。这就是本 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——[拦截 seam RFC](2026-06-30-interception-seams.md)),每条都是一次性支付的持久 `context/message`,之后被前缀缓存覆盖。 - **在压力门禁之前组合。** 组合先于实例的首次 `agent/pre-step`,且 seam 将组合值透传:`agent/pre-step` 携带 `sessionPrefix` 参数,`CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` 将其计入 token 压力估算。如果改为让门禁读取上一个实例折叠后的前缀,则在 resume 或 fork 后的实例中(贡献者可能已增长),门禁会低估压力、跳过压缩,发出超窗口的首个请求。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。被 cancel/dispose 中断的组合(中断落在 waterfall 内部)会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。 -由于组合在边界快照之前运行,组合监听器的会话追加会加入**当前**请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。 +由于组合在边界快照之前运行,组合监听器的会话追加会加入当前请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。 ## 测试 @@ -32,7 +32,7 @@ Status: implemented - **系统提示词分段**(`system-prompt/assemble`):对此类内容否决。assembly 渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带 header delta),而开场内容需要按实例冻结的语义。 - **持久化历史开场**(会话启动时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、跨 resume 陈旧。 - **按轮次组合而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制每次变化都产生 header delta;且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界,`'resume'` 快照已在那里可追溯地记录漂移。 -- **在首次请求时惰性组合,让压缩读取折叠后的 header**(最初合并时的形态):评审中被取代。折叠值仅从实例的第二个请求起才与活前缀匹配,因此在 resume/fork 后的实例首步,压力门禁读取的是**上一个**实例的前缀,可能低估压力。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。 +- **在首次请求时惰性组合,让压缩读取折叠后的 header**(最初合并时的形态):评审中被取代。折叠值仅从实例的第二个请求起才与活前缀匹配,因此在 resume/fork 后的实例首步,压力门禁读取的是上一个实例的前缀,可能低估压力。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。 - **专用会话事件承载前缀**:否决。header 事件按设计就是请求的非历史记录;第二个事件会为同一事实提供第二个归属,并多出一个需要保持完整的编解码器。 ## 后果 diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml index 5c62772145..0daf9b2490 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-repeat-tool-guard.md: 04d5d077a42b54ca7dc04a1efc9ea2f4034b642b -2026-07-08-repeat-tool-guard.zh.md: 917d958ca1019eb464b72b0201219de9dde7f658 +2026-07-08-repeat-tool-guard.md: e422ae70f61b6c77bf6517bc5eb840afc171d82c +2026-07-08-repeat-tool-guard.zh.md: eedb36f448bbca220e023a7609e1d9666fadb8e9 diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 04d5d077a4..e422ae70f6 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -1,9 +1,9 @@ # RFC: Repeat-tool-call guard plugin -English | [中文](2026-07-08-repeat-tool-guard.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md index 917d958ca1..eedb36f448 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.zh.md @@ -1,4 +1,4 @@ -# RFC:重复工具调用守卫插件 +# RFC: 重复工具调用守卫插件 Status: implemented diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index ba1e1f60be..45ea89f234 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-self-referential-cordis-toolset.md: 62b97dc4bdbd0e0b5b1f67f77c763065c79964ed -2026-07-08-self-referential-cordis-toolset.zh.md: 44648d7a3f195f2dc84121c0d014e5193484b106 +2026-07-08-self-referential-cordis-toolset.md: c79bc09dde85d79adc5435f3e08c702cab1369d9 +2026-07-08-self-referential-cordis-toolset.zh.md: e6cada0530652cfb40e7ea1edadf1b12e0f07e77 diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 62b97dc4bd..c79bc09dde 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -1,9 +1,9 @@ # RFC: The self-referential cordis toolset -English | [中文](2026-07-08-self-referential-cordis-toolset.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 44648d7a3f..e6cada0530 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -1,9 +1,9 @@ -# RFC:自引用 cordis 工具集 - -[English](2026-07-08-self-referential-cordis-toolset.md) | 中文 +# RFC: 自引用 cordis 工具集 Status: implemented +[English](2026-07-08-self-referential-cordis-toolset.md) | 中文 + ## 问题 本 harness 中的一切都是 cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。赋予模型这种能力值得探索——一个能审视并修改自身运行时的自引用 agent——但这同时引发三个正确性问题,本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制。 diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml b/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml index c824942f12..30fa8ab09e 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-session-query-service.md: 8b742ac19fea21d8404f5f44aa64f8c3cb3efccc -2026-07-10-session-query-service.zh.md: 73f47d0cc0306b3dcb6552c686f8f1a71ffbdc87 +2026-07-10-session-query-service.md: 8c990f4407d91c0be1fc01046e34aa3e76076ed4 +2026-07-10-session-query-service.zh.md: ea0d2d52668fb40b97621884f635cb03dea6f663 diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index 8b742ac19f..8c990f4407 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -1,9 +1,9 @@ # RFC: Exact session query service -English | [中文](2026-07-10-session-query-service.zh.md) - 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, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md index 73f47d0cc0..ea0d2d5266 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.zh.md @@ -1,9 +1,9 @@ -# RFC:精确会话查询服务 - -[English](2026-07-10-session-query-service.md) | 中文 +# RFC: 精确会话查询服务 Status: implemented +[English](2026-07-10-session-query-service.md) | 中文 + ## 问题 会话历史存在于两处:当前的 `SessionStore` 对象与可选的持久化后端。需要精确检查的消费方若无统一服务,就不得不各自重复实现活跃/持久化优先级判定、持久化生命周期处理、原始事件的 surface 分类以及防御性克隆。在检查点之间,持久化状态可能落后于活跃日志,因此仅靠持久化并非当前状态的可靠来源。 diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 6675490bb9..65e180d55d 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-subagent-persona-tool-filter-and-depth.md: 368f3a3592c5e241bb9357d4d4ce32e175c3de45 -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: cc78a472df014ce1eb9114277e0520c7e9c051bb +2026-07-12-subagent-persona-tool-filter-and-depth.md: c88695d4444008bff69fcb10e3cf33c7b8820b9b +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 1efbbce57274c11b2811ebe844c9cd80f81f407f diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 368f3a3592..c88695d444 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -1,9 +1,9 @@ # RFC: Configure subagent persona, tool visibility, and depth -English | [中文](2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) - 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. diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index cc78a472df..1efbbce572 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -1,9 +1,9 @@ -# RFC:配置 subagent 的人设、工具可见性与深度 - -[English](2026-07-12-subagent-persona-tool-filter-and-depth.md) | 中文 +# RFC: 配置 subagent 的人设、工具可见性与深度 Status: implemented +[English](2026-07-12-subagent-persona-tool-filter-and-depth.md) | 中文 + ## 问题 一个可复用的 subagent 提供方解决的是「如何运行子 agent(智能体)」的问题,但不同的委派工具需要不同的子 agent 行为。某个部署可能需要评审者人设、仅限研究的工具集,或硬性递归上限,而不必为每种组合创建新的提供方。 diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml index 120abae517..1e877bfba3 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-doc-sync-enforcement.md: 44ea84daddcae73ce07b0a8240f83ee9945e449d -2026-06-11-doc-sync-enforcement.zh.md: c739e9661bb926c772f1d5399529a813ac59991c +2026-06-11-doc-sync-enforcement.md: cb238291f9ff5ba5d6333cdf2fe75a43fe3e8eea +2026-06-11-doc-sync-enforcement.zh.md: a443cfcd6f9bd17ae1512683ff8cd82411fb8590 diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md index 44ea84dadd..cb238291f9 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md @@ -1,9 +1,9 @@ # RFC: Doc-sync enforcement -English | [中文](2026-06-11-doc-sync-enforcement.zh.md) - Status: implemented +English | [中文](2026-06-11-doc-sync-enforcement.zh.md) + ## Problem AGENTS.md promises that docs and code stay strictly in sync, but the promise was verified by eyeball. Review caught drift twice — a cookbook example contradicting the type policy, and a README citing the wrong `registerAdapter` call. Out-of-sync docs are worse than no docs, and this codebase is built primarily by agents that follow gates far more reliably than prose (mechanical quality gates). Two classes of doc drift are mechanically checkable: code blocks that no longer compile, and the event-taxonomy table that duplicates the `interface Events` declarations. diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md index c739e9661b..a443cfcd6f 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.zh.md @@ -1,9 +1,9 @@ -# RFC:Doc-sync 强制 - -[English](2026-06-11-doc-sync-enforcement.md) | 中文 +# RFC: Doc-sync 强制 Status: implemented +[English](2026-06-11-doc-sync-enforcement.md) | 中文 + ## 问题 AGENTS.md 承诺文档与代码严格同步,但这一承诺此前仅靠人眼核查。评审曾两次发现漂移:一次是实操手册(cookbook)示例与类型策略矛盾,一次是 README 引用了错误的 `registerAdapter` 调用。失去同步的文档比没有文档更糟;而本代码库主要由 agent(智能体)构建,agent 遵守门禁远比遵守行文约定可靠(机械质量门禁)。有两类文档漂移可以被机械检查:不再能编译的代码块,以及与 `interface Events` 声明重复的事件分类体系表。 @@ -15,7 +15,7 @@ AGENTS.md 承诺文档与代码严格同步,但这一承诺此前仅靠人眼 1. **`doc-typecheck`** 从 `README.md`、`docs/**` 和 `packages/*/README.md` 中提取所有 ` ```ts ` 围栏代码块,写入一个继承根 `tsconfig.json` 的临时项目,然后用 `tsc -b` 编译。临时项目复用源码的 `paths` 映射和根 project references,因此文档示例能看到源码,而 vendor 代码仍在其自身的 tsconfig 设置下被检查。刻意作为草图的代码块可通过显式的 ` ```ts ignore-check ` 信息字符串来 opt-out;脚本会报告 opt-out 比例,超过一半即失败,防止该豁免机制悄然成为常态。 2. **`verify-event-taxonomy`** 从 `packages/*/src` 中的 `interface Events` 块和 `docs/architecture.md` 中的分类体系表分别提取事件名称,断言两个集合完全一致。只校验,不生成:表格保留手写的 Mode/Purpose 列,仅检查名称集合。(落地此门禁时发现了表格遗漏的三个事件:`tools/change`、`llm/adapter-change`、`system-prompt/change`。)**已被取代**:由[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)取代。此门禁及其 `architecture.md` 表格已退役,取而代之的是完全生成的 `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` 及其 `verify-cordis-catalog` 新鲜度门禁。本 RFC 中的其他门禁(`doc-typecheck` 以及下文修订中的 `verify-md-wrap`)不受影响。 -两者通过一个共享的 doc-sync(文档同步门禁)`package.json` 脚本运行,lefthook pre-push 钩子和 CI 都调用它([机械质量门禁](2026-06-11-quality-gates.md):钩子与 CI 调用相同脚本,因此门禁在推送前就在本地触发,而非仅在推送后)。它们在 `pnpm run typecheck` 之后运行,后者校验 doc-typecheck 所引用的 package/vendor 构建图。 +两者通过一个共享的 `doc-sync`(文档同步门禁)package.json 脚本运行,lefthook pre-push 钩子和 CI 都调用它([机械质量门禁](2026-06-11-quality-gates.md):钩子与 CI 调用相同脚本,因此门禁在推送前就在本地触发,而非仅在推送后)。它们在 `pnpm run typecheck` 之后运行,后者校验 doc-typecheck 所引用的 package/vendor 构建图。 **修订(2026-06-17):** 第三道门禁 **`verify-md-wrap`** 随后被纳入 `doc-sync`。它使用 `mdast-util-from-markdown` + GFM 解析范围内的每个 Markdown 文件(`README.md`、`docs/**`、`packages/*/README.md`,加上 `AGENTS.md` / `packages/AGENTS.md`),如果任何 `paragraph` 节点跨越多个源码行则失败,从而强制执行 docs/AGENTS.md 中「一个段落一个物理行」的写作规则。同样遵循只校验不生成的原则:它报告硬换行但从不重写,因此不会引入格式化噪音。`doc-sync` 现在包含三道门禁。 diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml index ba5bb828a2..be4c70583e 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-quality-gates.md: 9862dc019dd6ff3b4639983821256395d0ee7b77 -2026-06-11-quality-gates.zh.md: a9c17bb700db091d21f7930942a8f3bbf55958a0 +2026-06-11-quality-gates.md: 2d6b6815e80a728cb61a86e9f7a488340fe06fc9 +2026-06-11-quality-gates.zh.md: f5088811f8562f5103740d39b48bd14c6d1da00c diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 9862dc019d..2d6b6815e8 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.md @@ -1,9 +1,9 @@ # RFC: Mechanical quality gates over prose guidelines -English | [中文](2026-06-11-quality-gates.zh.md) - Status: implemented +English | [中文](2026-06-11-quality-gates.zh.md) + ## Problem This codebase is developed primarily by coding agents. Agents follow enforced gates far more reliably than prose conventions, and "a lot of work" is not a cost argument when agents do the labor. Early evidence: tests that didn't typecheck shipped (vitest doesn't typecheck) and were only caught by a review. diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md index a9c17bb700..f5088811f8 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.zh.md @@ -1,9 +1,9 @@ -# RFC:以机械质量门禁取代行文约定 - -[English](2026-06-11-quality-gates.md) | 中文 +# RFC: 以机械质量门禁取代行文约定 Status: implemented +[English](2026-06-11-quality-gates.md) | 中文 + ## 问题 本代码库主要由 coding agent(智能体)开发。相比行文约定,agent 遵守强制门禁的可靠性远高得多;而当劳动由 agent 承担时,「工作量大」不构成成本论据。早期证据:未通过类型检查的测试被提交(vitest 不做类型检查),仅在评审中才被发现。 @@ -15,7 +15,7 @@ AGENTS.md 中的每一条承诺都对应一个以非零退出码表示失败的 - 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而 package/vendor 代码保持在各自 project-reference 边界之后。 - ESLint strict-type-checked + @stylistic(作为强制执行的统一代码风格),包括文件内重复逻辑检查;vendor 代码排除在外。 - jscpd 检测 package 生产 TypeScript 与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 -- `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */` 并注明理由,而非删除。 +- `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 - knip(死代码/依赖)、publint(包(package)正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 - lefthook pre-commit(lint 暂存文件、类型检查、vendor manifest(元数据清单)守卫)和 pre-push(测试、hygiene);CI 在 Node 22.19/24/26 上运行完整矩阵,外加一个驱动 echo-agent 端到端的演示冒烟测试。 diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml index 92dd0f65b6..bb496d19e3 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-tsdown-over-dumble.md: c16ac691a9452d952303cf73b40693447d25015c -2026-06-11-tsdown-over-dumble.zh.md: 5e9dc5242225e4420e1faa6ef19c8e8b9b3fdbcd +2026-06-11-tsdown-over-dumble.md: b1ce354b9b2baa042d8aa1be2a8de171ed4adfef +2026-06-11-tsdown-over-dumble.zh.md: 4bdd9939901ef376f2f6ccded609b644a4698495 diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md index c16ac691a9..b1ce354b9b 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md @@ -1,9 +1,9 @@ # RFC: tsdown for JS bundling instead of dumble -English | [中文](2026-06-11-tsdown-over-dumble.zh.md) - Status: implemented +English | [中文](2026-06-11-tsdown-over-dumble.zh.md) + ## Problem The initial build used **dumble**, the cordiverse zero-config esbuild wrapper that upstream Cordis itself builds with — maximum alignment with the vendored packages' conventions (it reads each package.json and infers entries/formats from the `exports` field). But dumble is a liability as a load-bearing tool in this repo: v0.2.x, ~530 npm downloads/week, effectively one maintainer, and we were invoking it through a custom orchestration script (`scripts/build.ts`) because it has no workspace mode. diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md index 5e9dc52422..4bdd993990 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.zh.md @@ -1,9 +1,9 @@ -# RFC:使用 tsdown 替代 dumble 进行 JS 打包 - -[English](2026-06-11-tsdown-over-dumble.md) | 中文 +# RFC: 使用 tsdown 替代 dumble 进行 JS 打包 Status: implemented +[English](2026-06-11-tsdown-over-dumble.md) | 中文 + ## 问题 最初的构建使用 **dumble**,即 cordiverse 的零配置 esbuild 包装层——上游 Cordis 自身也用它构建——与 vendor 包(package)的约定最大程度对齐(它读取每个 package.json 并从 `exports` 字段推断入口/格式)。但 dumble 作为本仓库的承重工具存在隐患:v0.2.x,每周约 530 次 npm 下载,实质上只有一位维护者,而且由于它没有 workspace 模式,我们不得不通过自定义编排脚本(`scripts/build.ts`)来调用它。 diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml index b71b7d025d..339d733f07 100644 --- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-vendor-cordis-as-source.md: 39506300dec73d0c9eb1b7b2246caa23f1b10f7f -2026-06-11-vendor-cordis-as-source.zh.md: 0e794d97c4d535b74279bab11bda519e7da2e366 +2026-06-11-vendor-cordis-as-source.md: 6e1af616785411a20496334fb35e0c7a3bc41b43 +2026-06-11-vendor-cordis-as-source.zh.md: bb9413fbba34e9a7040fd2e0a1bbcff1ba2345ff diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md index 39506300de..6e1af61678 100644 --- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md @@ -1,9 +1,9 @@ # RFC: Vendor Cordis as source, not npm dependencies -English | [中文](2026-06-11-vendor-cordis-as-source.zh.md) - Status: implemented +English | [中文](2026-06-11-vendor-cordis-as-source.zh.md) + ## Problem DeepSeek Harness SDK is built on the Cordis framework. Cordis core was at 4.0.0-rc.6 (a release candidate) when this repo started; the harness depends on framework internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact behavior matters to the agent loop's correctness guarantees. diff --git a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md index 0e794d97c4..bb9413fbba 100644 --- a/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md +++ b/docs/rfc/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md @@ -1,9 +1,9 @@ -# RFC:将 Cordis 以源码形式收录,而非作为 npm 依赖 - -[English](2026-06-11-vendor-cordis-as-source.md) | 中文 +# RFC: 将 Cordis 以源码形式收录,而非作为 npm 依赖 Status: implemented +[English](2026-06-11-vendor-cordis-as-source.md) | 中文 + ## 问题 DeepSeek Harness SDK 构建于 Cordis 框架之上。本仓库启动时,Cordis core 处于 4.0.0-rc.6(一个候选发布版本);harness 依赖框架内部实现(fiber 生命周期、dispose(资源释放)、waterfall(瀑布式事件)分发),其确切行为直接关系到 agent loop(智能体循环)的正确性保证。 diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index ec328ff0e0..41084092d0 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-16-pnpm-over-yarn.md: 6e7a6e1f53056e36f54f44b87b305afa593da549 -2026-06-16-pnpm-over-yarn.zh.md: ba63575909f2bafbbad2102c85d6bede77999997 +2026-06-16-pnpm-over-yarn.md: f5787dc3019f2c474761972afb11deb42f3950e0 +2026-06-16-pnpm-over-yarn.zh.md: 0f5acd13f6f113a569fbe4e7b92df62309c76537 diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md index 6e7a6e1f53..f5787dc301 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -1,9 +1,9 @@ # RFC: pnpm as the package manager instead of Yarn 4 -English | [中文](2026-06-16-pnpm-over-yarn.zh.md) - Status: implemented +English | [中文](2026-06-16-pnpm-over-yarn.zh.md) + ## Problem The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers. diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index ba63575909..0f5acd13f6 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -1,9 +1,9 @@ -# RFC:使用 pnpm 替代 Yarn 4 作为包管理器 - -[English](2026-06-16-pnpm-over-yarn.md) | 中文 +# RFC: 使用 pnpm 替代 Yarn 4 作为包管理器 Status: implemented +[English](2026-06-16-pnpm-over-yarn.md) | 中文 + ## 问题 本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。 diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 92e8c81d4b..413c51c906 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-ts-build-config.md: cf70014b5873f74da8476c21dd71feedb956f59f -2026-06-17-ts-build-config.zh.md: d3dd0fb13edd22f1ae365286cbf8144fa0bc3f69 +2026-06-17-ts-build-config.md: 9a75bcc3f043576f1cb793c38e0166aa0a010f68 +2026-06-17-ts-build-config.zh.md: c0f6bd40f3f0b06e79b9dc9f9c7812481413374c diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md index cf70014b58..9a75bcc3f0 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md @@ -1,9 +1,9 @@ # RFC: TSC-first build and one tsconfig -English | [中文](2026-06-17-ts-build-config.zh.md) - Status: implemented +English | [中文](2026-06-17-ts-build-config.zh.md) + ## Problem The current TypeScript build and typecheck setup had these issues: diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md index d3dd0fb13e..c0f6bd40f3 100644 --- a/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.zh.md @@ -1,9 +1,9 @@ -# RFC:TSC 优先的构建与单一 tsconfig - -[English](2026-06-17-ts-build-config.md) | 中文 +# RFC: TSC 优先的构建与单一 tsconfig Status: implemented +[English](2026-06-17-ts-build-config.md) | 中文 + ## 问题 此前的 TypeScript 构建与类型检查配置存在以下问题: @@ -17,7 +17,7 @@ Status: implemented - `tsdown` 使用 `oxc` 进行 TypeScript 转换,其行为与 `tsc` 不同。 - `tsdown` 输出的打包 `.d.ts` 与 Cordis 内部的相对模块增强(module augmentation)结构冲突。 - - `tsc` 的输出受 `allowImportingTsExtensions` 影响,因此需要确保生成的 `.js` 文件不会导入 `.ts` 文件,且生成的 `.d.ts` 文件保留 NodeNext/Node16 接受的显式相对说明符。为此,包内相对导入在 TypeScript 源码中使用显式 `.ts` 说明符,由 `rewriteRelativeImportExtensions` 在输出的 JS 中将其重写为 `.js`。 + - tsc 的输出受 `allowImportingTsExtensions` 影响,因此需要确保生成的 `.js` 文件不会导入 `.ts` 文件,且生成的 `.d.ts` 文件保留 NodeNext/Node16 接受的显式相对说明符。为此,包内相对导入在 TypeScript 源码中使用显式 `.ts` 说明符,由 `rewriteRelativeImportExtensions` 在输出的 JS 中将其重写为 `.js`。 - `tsdown` 输出的打包 `.js` 与 `tsc -b` 逐文件输出的 `.js` 行为不同,例如装饰器转换行为。 - `vendor/*/src`、示例、测试和脚本无法全部以 plain-include 方式纳入一个根目录的严格程序。 - 在根目录严格配置下直接对 `vendor/*/src` 做类型检查,会触发大量不属于本项目所有权范围的类型错误。 diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml index 2b7b62f141..0f0677a911 100644 --- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-markdown-cross-link-lint.md: c802b4071abf652824647e4417cde3f518776353 -2026-06-18-markdown-cross-link-lint.zh.md: 917580ffce71258896f3d23ef7efef4be0176949 +2026-06-18-markdown-cross-link-lint.md: f7ab6a4fd2b2c5cadaeabd5ef0c89f1097b5b44d +2026-06-18-markdown-cross-link-lint.zh.md: 61187042a3a0d3535479ffe3a796b6c1fdb82f5e diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md index c802b4071a..f7ab6a4fd2 100644 --- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md @@ -1,9 +1,9 @@ # RFC: Markdown cross-link validity linting -English | [中文](2026-06-18-markdown-cross-link-lint.zh.md) - Status: implemented +English | [中文](2026-06-18-markdown-cross-link-lint.zh.md) + ## Problem Docs in this repo link to each other by relative path — `[topic](../implemented/2026-…-….md)`, `[the cookbook](adding-a-tool.md)`, `[architecture.md](../../architecture.md)`. Nothing verified those targets exist. A rename or a move silently breaks every inbound link, and the break is invisible until a reader clicks it. [Doc-sync enforcement](2026-06-11-doc-sync-enforcement.md) already mechanized two classes of doc drift (uncompilable code blocks, a stale event-taxonomy table) and [verify-md-wrap](2026-06-11-doc-sync-enforcement.md) a third (hard-wrapped prose) — but a dead cross-link is a fourth, equally mechanical class that was still verified by eyeball. diff --git a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md index 917580ffce..61187042a3 100644 --- a/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md +++ b/docs/rfc/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md @@ -1,9 +1,9 @@ -# RFC:Markdown 交叉链接有效性检查 - -[English](2026-06-18-markdown-cross-link-lint.md) | 中文 +# RFC: Markdown 交叉链接有效性检查 Status: implemented +[English](2026-06-18-markdown-cross-link-lint.md) | 中文 + ## 问题 本仓库的文档通过相对路径互相链接:`[topic](../implemented/2026-…-….md)`、`[the cookbook](adding-a-tool.md)`、`[architecture.md](../../architecture.md)`。此前没有任何机制验证这些目标是否存在。重命名或移动文件会静默破坏所有指向它的链接,且在读者点击之前不可见。[Doc-sync 强制](2026-06-11-doc-sync-enforcement.md)已经将两类文档漂移机械化(无法编译的代码块、陈旧的事件分类表),[verify-md-wrap](2026-06-11-doc-sync-enforcement.md) 覆盖了第三类(硬换行的段落),但死链是第四类同样可机械检查、却仍靠肉眼验证的问题。 @@ -20,7 +20,7 @@ Status: implemented 范围与其他门禁一致,另外加上 AGENTS.md 对和 `.agents/skills/` 下仓库自有的 agent skill Markdown(这些 skill 文件交叉链接到 docs 目录,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`,按真实路径去重(`CLAUDE.md` 符号链接解析到 AGENTS.md 文件)。它接入 lefthook pre-push 钩子和 CI 都会运行的 `doc-sync` 脚本,因此死链在推送前就会在本地失败——与[机械化质量门禁](2026-06-11-quality-gates.md)一致。 -本门禁检查的是**文件存在性**,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件可解析;片段被剥除)。 +本门禁检查的是*文件存在性*,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件可解析;片段被剥除)。 ## 曾考虑的替代方案 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 4345634014..7b498b0a81 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-core-data-structures-catalog.md: 5f1232f2f0d0644d4043af217a7177451155030b -2026-06-20-core-data-structures-catalog.zh.md: 8ad4453890d8be5dc4e743d1f6f9aa6a9330ed17 +2026-06-20-core-data-structures-catalog.md: 933844d0f442306af238bfc459372b1c97414f87 +2026-06-20-core-data-structures-catalog.zh.md: 620457b28d6b4aa36ec139da7bcd892ed78e2673 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index 5f1232f2f0..933844d0f4 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -1,9 +1,9 @@ # RFC: Core-data-structures catalog and the `ts type-equiv` drift gate -English | [中文](2026-06-20-core-data-structures-catalog.zh.md) - Status: implemented +English | [中文](2026-06-20-core-data-structures-catalog.zh.md) + ## Problem A reader trying to understand the harness could find its *behavior* in [architecture.md](../../../architecture.md) (the service map, the session/turn/step lifecycle, the event taxonomy) but had no single place describing its *vocabulary* — the data structures that behavior moves around. The type shapes lived only in source, scattered across `packages/*/src/types.ts`, so understanding "what is a `Message`, a `SessionEvent`, a `StreamChunk`" meant reading the declarations directly. A prose catalog would help, but a catalog that paraphrases or paste-copies type definitions rots the instant a field changes — and an out-of-sync type doc is worse than none, because a reader trusts it. diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 8ad4453890..620457b28d 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC:核心数据结构目录与 `ts type-equiv` 漂移门禁 +# RFC: 核心数据结构目录与 `ts type-equiv` 漂移门禁 Status: implemented @@ -22,7 +22,7 @@ Status: implemented - 一个数据结构是**核心**的,如果它流经 agent loop 主干——无论加载了哪些插件,循环在每个轮次都会持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄)——**或者**它是插件作者面对某条流水线时编写的唯一标志性类型(`ToolDefinition`)。 - `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标志性类型,撰写重要性压过了严格的"流经主干"规则。但它的类型推导机制——`SchemaSpec`/`InferArgs` DSL——是子页面细节(你编写的是 `ToolDefinition`;为其提供类型推导的机制你并不直接接触)。这就是主干与 seam 分界线的精确表述。 -- `ToolSchema` 是核心(它是 `GenerateOptions` 的一个字段,而 `GenerateOptions` 是流经每个步骤的模型请求),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 +- `ToolSchema` 是核心(它是流经每个步骤的模型请求 `GenerateOptions` 的一个字段),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 - 工具展示词汇(`ToolCallView`/`ToolResultView` 等)、`SessionPersistence` 持久性 seam 以及 bash 词汇是子页面。 `core.md` 是一份**自包含的主干文档**:它给出每个主干结构的确切类型定义,辅以最少的行文,并链接到子页面获取各 seam 的细节。子页面包括 `llm-streaming.md`、`session.md`、`persistence.md`(沿内存模型与持久性 seam 的分界线从 session 拆出)、`tools.md` 和 `bash.md`。 @@ -56,5 +56,5 @@ Status: implemented - 词汇现在有了一个**不会静默漂移**的唯一归属:源码中的字段重命名会在 pre-push 钩子和 CI 中导致 `verify-type-equiv` 失败,直到粘贴内容被刷新。 - 主干与 seam 分界线是一个可复用的范围界定工具,而非一次性的:同一条「你编写/持有/接收的东西是核心;为其提供类型推导/渲染/持久化的机制是细节」规则,后来也被用于界定事件/服务目录的 harness 层与继承层分层。 -- ` ```ts type-equiv ` 围栏是继 ` ```ts `(编译)和 ` ```ts ignore-check `(草稿)之后的第三种文档块类别。后续的姊妹门禁又增加了第四种 ` ```ts cordis-catalog `(生成签名),复用了相同的跳过并排除处理。 +- `ts type-equiv` 围栏是继 ` ```ts `(编译)和 ` ```ts ignore-check `(草稿)之后的第三种文档块类别。后续的姊妹门禁又增加了第四种 ` ```ts cordis-catalog `(生成签名),复用了相同的跳过并排除处理。 - 添加或重塑核心类型现在附带一项文档义务,作者必须履行(门禁无法检测缺失的*新*类型),由 `dsh-code-review` 检查清单兜底。 diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml index 9f7e982ff7..13f31c3ebe 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-generated-cordis-catalog.md: 6b451e31965f8f00210aa927ed236fed28699351 -2026-06-20-generated-cordis-catalog.zh.md: 5550d07b5f5635d1da6496e35049c5725329e114 +2026-06-20-generated-cordis-catalog.md: 8b979764c1ae6693b77817fc84381e40e56f8d36 +2026-06-20-generated-cordis-catalog.zh.md: 608ab3e0429fab43abec634ce0955f46306d8529 diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index 6b451e3196..8b979764c1 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -1,9 +1,9 @@ # RFC: Generated cordis events + services catalog -English | [中文](2026-06-20-generated-cordis-catalog.zh.md) - Status: implemented +English | [中文](2026-06-20-generated-cordis-catalog.zh.md) + ## Problem A plugin author needs two reference surfaces that no single document gave them: every cordis **event** they can listen to (with its exact signature and dispatch mode) and every `ctx.<key>` **service** they can call (with its exact interface). The pieces existed but were scattered — a hand-maintained event-taxonomy *table* in `docs/architecture.md` (names + prose Mode/Purpose, name-set-checked by `verify-event-taxonomy`), a Service-map table (8 rows of role prose), and the `interface Events` / `interface Context` declarations themselves. The taxonomy table also could not catch a brand-new *undocumented* event: a name-set verifier only checks the names that are already in the table on both sides. diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md index 5550d07b5f..608ab3e042 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC:生成式 Cordis 事件与服务目录 +# RFC: 生成式 Cordis 事件与服务目录 Status: implemented @@ -21,8 +21,8 @@ Status: implemented 具体选择: - **`@mode` 标签,交叉校验。** 每个 harness 事件的 JSDoc 携带一个显式的 `@mode emit|waterfall|parallel|serial` 标签;缺少标签时生成器直接报错。当签名形状具有决定性时——尾部参数为 `next: () => …` 在结构上即为 waterfall(瀑布式事件)——生成器断言标签与之一致,矛盾时直接报错。emit/parallel/serial 的区别在结构上不可见(`session/flush` 返回 `Promise<void> | void` 且无 `next`,有序的 `agent/pre-step` 检查点亦然),因此信任标签。编写规则见 [AGENTS.md](../../../../AGENTS.md)。 -- **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/hmr/timer)是插件同样可见的固定 vendor 源码;它从生成器中一张人工维护的表格简洁渲染(名称 + 一行描述 + 源码指针),而**非**遍历 vendor AST。原因是 cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 接口面仅在有意的 vendor 同步时才变化。 -- **交叉链接到数据结构目录。** 签名中的类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition` 等)链接到记录该类型的 core-data-structures 页面。映射是生成器中一个小型的人工维护常量,而**非** `type-equiv.manifest.json`——后者记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 +- **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/hmr/timer)是插件同样可见的固定 vendor 源码;它从生成器中一张人工维护的表格简洁渲染(名称 + 一行描述 + 源码指针),而非遍历 vendor AST。原因是 cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 接口面仅在有意的 vendor 同步时才变化。 +- **交叉链接到数据结构目录。** 签名中的类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition` 等)链接到记录该类型的 core-data-structures 页面。映射是生成器中一个小型的人工维护常量,而非 `type-equiv.manifest.json`——后者记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 - **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,`doc-typecheck` 识别后跳过(裸签名片段不能独立编译),并排除在 opt-out 比例之外——与 `type-equiv` 块获得相同待遇。 本决策**取代** [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)中事件分类的那一半:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射的角色表格作为人工行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 和 verify-type-equiv 不受影响。 diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml b/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml index cc4642657c..c9a4c3a6e9 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-rfc-classification.md: 201852a209be7f40b05de45d148a36b9185767a3 -2026-06-20-rfc-classification.zh.md: 554ac8014719c99ed447a33ff843c40fde761eed +2026-06-20-rfc-classification.md: 6975b74dbaa9ca9379f23b537133323cb694958a +2026-06-20-rfc-classification.zh.md: 423d5c87eb9e2cb07ed18834f4c15ffa1ac1983e diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md index 201852a209..6975b74dba 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md @@ -1,9 +1,9 @@ # RFC: Classify RFCs by kind via path-encoded subdirectories -English | [中文](2026-06-20-rfc-classification.zh.md) - Status: implemented +English | [中文](2026-06-20-rfc-classification.zh.md) + ## Problem `docs/rfc/` grouped RFCs by **lifecycle** only — `proposed/` / `implemented/` / `rejected/`. Nothing recorded what *kind* of decision each RFC was. The index was one flat list per lifecycle, with no way to scan "show me every simplification" or "every testing-strategy decision." A wave of simplification RFCs landing on the same day made the gap concrete: a reader skimming `proposed/` could not tell a new capability from a removal from a tooling-policy change without opening each file. diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md index 554ac80147..423d5c87eb 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.zh.md @@ -1,9 +1,9 @@ -# RFC:通过路径编码的子目录对 RFC 进行分类 - -[English](2026-06-20-rfc-classification.md) | 中文 +# RFC: 通过路径编码的子目录对 RFC 进行分类 Status: implemented +[English](2026-06-20-rfc-classification.md) | 中文 + ## 问题 `docs/rfc/` 过去仅按**生命周期**分组 RFC:`proposed/`/`implemented/`/`rejected/`。没有任何机制记录每个 RFC 属于哪一*类*决策。索引在每个生命周期下只是一个扁平列表,无法按需筛选「所有简化类」或「所有测试策略类」决策。一批简化类 RFC 在同一天落地后,这个缺口变得具体:浏览 `proposed/` 的读者无法在不逐一打开文件的情况下区分新能力、移除和工具策略变更。 @@ -12,7 +12,7 @@ Status: implemented ## 决策 -增加第二个维度——RFC 的**类别**——并将其编码在路径中:`{lifecycle}/{class}/yyyy-mm-dd-topic.md`。文件夹本身就是标签。文件的位置声明其类别,封闭集合是「这些文件夹且仅限这些」,而既有的 [verify-md-links](2026-06-18-markdown-cross-link-lint.md) 门禁已经保护了移动文件所需的路径重写。 +增加第二个维度——RFC 的**类别**——并将其编码在路径中:`{lifecycle}/{class}/yyyy-mm-dd-topic.md`。文件夹本身*就是*标签。文件的位置声明其类别,封闭集合是「这些文件夹且仅限这些」,而既有的 [verify-md-links](2026-06-18-markdown-cross-link-lint.md) 门禁已经保护了移动文件所需的路径重写。 ### 六个类别的封闭集合 @@ -22,7 +22,7 @@ Status: implemented | `bug-fix` | 修正缺陷或填补事后复盘暴露的空白。 | | `simplification` | 移除代码、行为或对外表面积,不引入新能力。 | | `architecture` | 关于**交付源码**的结构性决策——包(package)之间的关系、运行时词汇。 | -| `process` | 围绕代码的工具、策略或工作流,而非运行时行为。 | +| `process` | **围绕**代码的工具、策略或工作流,而非运行时行为。 | | `testing` | 测试基础设施与策略。 | `architecture` 与 `process` 的分界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。本 RFC 本身是一个 `process` 决策——它改变的是仓库的组织方式和门禁,而非 harness 的运行时行为——因此它位于 `implemented/process/` 下。 diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml index 8ecca3648d..80865d0269 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-tool-schema-catalog.md: 9a99fafd36b3546be4f51a7cd9e9a47fdaaf4c2d -2026-07-02-tool-schema-catalog.zh.md: 5860d1617ce6592c8665e4cb59304b0f6d35c99a +2026-07-02-tool-schema-catalog.md: fe7ec47ac89c157482f612a374ca8773d7d0867f +2026-07-02-tool-schema-catalog.zh.md: 1567ff5dd5c53da501a86412d0e29127d33d29f6 diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 9a99fafd36..fe7ec47ac8 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -1,9 +1,9 @@ # RFC: Generated tool-schema catalog (boot-and-harvest) -English | [中文](2026-07-02-tool-schema-catalog.zh.md) - Status: implemented +English | [中文](2026-07-02-tool-schema-catalog.zh.md) + ## Problem The repository had no single reference for the names, descriptions, and JSON Schemas actually exposed to the model. Source declarations are scattered and runtime-composed, while the existing Cordis and data-structure catalogs cover wiring and vocabulary rather than tools. diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md index 5860d1617c..1567ff5dd5 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -1,16 +1,16 @@ -# RFC:生成式工具 schema 目录(启动并采集) - -[English](2026-07-02-tool-schema-catalog.md) | 中文 +# RFC: 生成式工具 schema 目录(启动并采集) Status: implemented +[English](2026-07-02-tool-schema-catalog.md) | 中文 + ## 问题 仓库此前没有一份统一的参考文档来记录实际暴露给模型的工具名称、描述与 JSON Schema。源码声明分散各处且在运行时组合,而既有的 Cordis 目录和数据结构目录覆盖的是接线与词汇,而非工具。 ## 决策 -通过**启动每个工具插件并读取其注册的 schema** 来生成目录,而非解析源码。`scripts/gen-tool-catalog.ts` 将每个已发布的工具包(package)挂载到一个新的 Cordis `Context`(带 `SystemPrompt` + `ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`(即发送给模型的 `ToolSchema[]`),dispose(资源释放)该 context,然后为每个包渲染一个 `## <package>` 小节,每个工具一个 ` ```json ` 的 `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI(命令行界面)形态一致:默认 `--write` 重新生成,`--check` 在已提交副本陈旧时失败,输出是确定性的(按 manifest(元数据清单)排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 doc-sync(文档同步门禁)内运行,因此新鲜度门禁在 lefthook pre-push 和 CI 路径中与其他文档门禁一同触发。 +通过**启动每个工具插件并读取其注册的 schema** 来生成目录,而非解析源码。`scripts/gen-tool-catalog.ts` 将每个已发布的工具包(package)挂载到一个新的 Cordis `Context`(带 `SystemPrompt` + `ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`(即发送给模型的 `ToolSchema[]`),dispose(资源释放)该 context,然后为每个包渲染一个 `## <package>` 小节,每个工具一个 ` ```json ` 的 `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI(命令行界面)形态一致:默认 `--write` 重新生成,`--check` 在已提交副本陈旧时失败,输出是确定性的(按 manifest(元数据清单)排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync`(文档同步门禁)内运行,因此新鲜度门禁在 lefthook pre-push 和 CI 路径中与其他文档门禁一同触发。 ### 为何启动而非解析(核心要点) @@ -25,7 +25,7 @@ Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是 ### 恢复「不会静默遗漏」的保证 -启动有一项 AST 遍历不存在的代价:没有源码声明集合可供枚举,新工具包可能被遗忘。一个**完整性守卫**恢复了这项保证——`assertManifestComplete` 对 `packages/` 下所有 `tool-*` 包进行 glob,若有任何一个不在生成器的启动 manifest 中则直接报错。新工具包在注册之前会导致生成器失败,进而导致 doc-sync 失败。这与 Cordis 生成器通过枚举源码免费获得的结构性属性相同,只是为基于启动的生成器重新实现了一遍。 +启动有一项 AST 遍历不存在的代价:没有源码声明集合可供枚举,新工具包可能被遗忘。一个**完整性守卫**恢复了这项保证——`assertManifestComplete` 对 `packages/` 下所有 `tool-*` 包进行 glob,若有任何一个不在生成器的启动 manifest 中则直接报错。新工具包在注册之前会导致生成器失败,进而导致 `doc-sync` 失败。这与 Cordis 生成器通过枚举源码免费获得的结构性属性相同,只是为基于启动的生成器重新实现了一遍。 ### 手动维护的启动 manifest 是不可化约的策略 diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml index 6b19ed7740..d5ad8c6180 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-03-documentation-graph-atlas.md: d10b57e5114684ab0a2caed66fa84b84959bdf12 -2026-07-03-documentation-graph-atlas.zh.md: 6edcfbdbbc3af5808e60888acad04919ce681396 +2026-07-03-documentation-graph-atlas.md: 3cb8e685452a5b013798d0c3f661fca218b3d69d +2026-07-03-documentation-graph-atlas.zh.md: f03268c63c6d834c59af6cf831a34e5a5eaf40d7 diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md index d10b57e511..3cb8e68545 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -1,9 +1,9 @@ # RFC: Documentation graph index for maintainers and SDK users -English | [中文](2026-07-03-documentation-graph-atlas.zh.md) - Status: implemented +English | [中文](2026-07-03-documentation-graph-atlas.zh.md) + ## Problem The repo already had several high-trust documentation surfaces, each on a different axis: [module-graph.md](../../../module-graph.md) is generated from package `peerDependencies`, the generated [Cordis events](../../../cordis-catalog/events.md) and [services](../../../cordis-catalog/services.md) catalogs are generated from Cordis `Events` and `Context` declarations, [tool-catalog.md](../../../tool-catalog.md) is generated by booting shipped tool plugins, and [core-data-structures/](../../../core-data-structures/core.md) uses `ts type-equiv` blocks to keep pasted type definitions synchronized with source. diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md index 6edcfbdbbc..f03268c63c 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -1,9 +1,9 @@ -# RFC:面向维护者与 SDK 用户的文档关系图索引 - -[English](2026-07-03-documentation-graph-atlas.md) | 中文 +# RFC: 面向维护者与 SDK 用户的文档关系图索引 Status: implemented +[English](2026-07-03-documentation-graph-atlas.md) | 中文 + ## 问题 仓库已有若干高可信度的文档面,各自覆盖不同维度:[module-graph.md](../../../module-graph.md) 由包(package)的 `peerDependencies` 生成;生成的 [Cordis events](../../../cordis-catalog/events.md) 与 [services](../../../cordis-catalog/services.md) 目录由 Cordis 的 `Events` 和 `Context` 声明生成;[tool-catalog.md](../../../tool-catalog.md) 通过启动已发布的 tool 插件生成;[core-data-structures/](../../../core-data-structures/core.md) 使用 `ts type-equiv` 块保持粘贴的类型定义与源码同步。 diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml index 80b4958372..520426ca4b 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-cordis-jsdoc-completeness-gate.md: 44a0ddce9c929deac3e03bb421aec1d5145e65ba -2026-07-04-cordis-jsdoc-completeness-gate.zh.md: 073054072748bf6cfed09cdcc222087bcc0ed929 +2026-07-04-cordis-jsdoc-completeness-gate.md: 8eaa997eb734b32e7e4a45d96def15dc541af1d5 +2026-07-04-cordis-jsdoc-completeness-gate.zh.md: a49189b3162fbd0eba9e043c683f0fc56d0bf7d6 diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md index 44a0ddce9c..8eaa997eb7 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -1,9 +1,9 @@ # RFC: JSDoc completeness gate for the cordis surface -English | [中文](2026-07-04-cordis-jsdoc-completeness-gate.zh.md) - Status: implemented +English | [中文](2026-07-04-cordis-jsdoc-completeness-gate.zh.md) + ## Problem The generated Cordis catalog enforced event dispatch modes but not complete service and event contracts. Methods could lack descriptions, and parameters or returns could be undocumented on the cross-plugin API surface where IDE guidance matters most. diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md index 0730540727..a49189b316 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md @@ -1,9 +1,9 @@ -# RFC:针对 Cordis 对外服务接口的 JSDoc 完整性门禁 - -[English](2026-07-04-cordis-jsdoc-completeness-gate.md) | 中文 +# RFC: 针对 Cordis 对外服务接口的 JSDoc 完整性门禁 Status: implemented +[English](2026-07-04-cordis-jsdoc-completeness-gate.md) | 中文 + ## 问题 生成的 Cordis 目录此前强制了事件分发模式,但未强制要求完整的服务与事件契约。方法可以缺少描述,参数或返回值可以在跨插件 API 接口上不写文档——而这恰恰是 IDE 引导最重要的地方。 @@ -12,7 +12,7 @@ AGENTS.md 中的规则(「每个导出都有解释语义的 JSDoc」)只能 ## 决策 -扩展 `scripts/gen-cordis-catalog.ts`(同一次遍历、同一个 `@mode` 先例),对其编目的所有内容强制 JSDoc 完整性。`verify-cordis-catalog` 在 `doc-sync`(文档同步门禁)内运行,CI 和 lefthook pre-push 钩子都已执行 `doc-sync`,因此门禁无需新增任何接线(质量门禁原则:单一真源)。 +扩展 `scripts/gen-cordis-catalog.ts`(同一次遍历、同一个 `@mode` 先例),对其编目的所有内容强制 JSDoc 完整性。`verify-cordis-catalog` 在 `doc-sync`(文档同步门禁)内运行,CI 和 lefthook pre-push 钩子都已执行该命令,因此门禁无需新增任何接线(质量门禁原则:单一真源)。 契约如下: diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index d6465ad8c1..15039922c5 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-doc-tiers-and-budgets.md: ca9da847849f61f2fb244932e657cca8fec69696 -2026-07-04-doc-tiers-and-budgets.zh.md: ae34e3f04d7986f78d1b3ceeaacb3bc4c7bc64f5 +2026-07-04-doc-tiers-and-budgets.md: ddbce540c68b9b35bb7e6a48b8db8abbc3a7a398 +2026-07-04-doc-tiers-and-budgets.zh.md: d3f08d337af34b5def41792232330180123dd585 diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md index ca9da84784..ddbce540c6 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -1,9 +1,9 @@ # RFC: Documentation tiers, budgets, and the ceiling gate -English | [中文](2026-07-04-doc-tiers-and-budgets.zh.md) - Status: implemented +English | [中文](2026-07-04-doc-tiers-and-budgets.zh.md) + ## Problem Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale RFC summaries despite existing writing guidance. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy. diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index ae34e3f04d..d3f08d337a 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -1,9 +1,9 @@ -# RFC:文档分层、预算与上限门禁 - -[English](2026-07-04-doc-tiers-and-budgets.md) | 中文 +# RFC: 文档分层、预算与上限门禁 Status: implemented +[English](2026-07-04-doc-tiers-and-budgets.md) | 中文 + ## 问题 尽管已有写作指导,常设文档仍然积累了重复的规则、重述的事故、重复的包(package)映射和陈旧的 RFC 摘要。仅靠评审无法阻止这种膨胀,因此仓库需要在文档分类体系之外增加一道机械化的预算约束。 diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml index 8d410ae5f8..da8988c6ea 100644 --- a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-generate-rfc-index-tables.md: 6a8888eda8b7cf7105a44802774bf49d6463952d -2026-07-04-generate-rfc-index-tables.zh.md: aad1652edc9de81a70a7a391700f63be9499035c +2026-07-04-generate-rfc-index-tables.md: 5d5ae258583e0fae7dcb44a23c4b41446bd233d2 +2026-07-04-generate-rfc-index-tables.zh.md: fa611e4337075501fe097fe8fc68a878a855a7c0 diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md index 6a8888eda8..5d5ae25858 100644 --- a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md @@ -1,9 +1,9 @@ # RFC: Generate the RFC index tables -English | [中文](2026-07-04-generate-rfc-index-tables.zh.md) - Status: implemented +English | [中文](2026-07-04-generate-rfc-index-tables.zh.md) + ## Problem The RFC index's per-lifecycle/per-class tables list facts that are fully derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts is also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](2026-06-20-rfc-classification.md) originally kept the index hand-written for curation's sake — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do. diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md index aad1652edc..fa611e4337 100644 --- a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md @@ -1,9 +1,9 @@ -# RFC:生成 RFC 索引表 - -[English](2026-07-04-generate-rfc-index-tables.md) | 中文 +# RFC: 生成 RFC 索引表 Status: implemented +[English](2026-07-04-generate-rfc-index-tables.md) | 中文 + ## 问题 RFC 索引中按生命周期/按分类的表格所列信息完全可以推导:RFC 的路径编码了生命周期与分类,文件名编码了首次提出日期,H1 标题承载了标题文本。这些信息的手工维护副本也是仓库中冲突最频繁的文档热点:每一波提案都在同几行后追加新行,因此并发的 RFC 分支恰好在此处冲突,而其他地方完全一致;每次冲突都要手工合并那些文件系统本已知晓的行。[分类 RFC](2026-06-20-rfc-classification.md) 最初为了可策展性而保留手写索引,但 README 中真正需要策展的是行文,而行文从不冲突;冲突的只有机械表格。 @@ -13,7 +13,7 @@ RFC 索引中按生命周期/按分类的表格所列信息完全可以推导: 保留策展行文;生成列表。表格位于 [`docs/rfc/INDEX.md`](../../INDEX.md),是一个**完全生成的文件**——策展行文留在 README.md 中,README.md 不包含任何索引行。[`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) 是共享的真源:树遍历器(拥有封闭的生命周期/分类集合与结构规则,包括对可解析 H1 的要求)和渲染器(行来自 H1 标题并去掉 `RFC: ` 前缀,加上文件名日期,按日期再按文件名排序,以 `### {Class}` 分节、按规范分类顺序分组)。两个轻量消费方共享它: - [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts)(`pnpm run gen-rfc-index`)从目录树完整重写 INDEX.md。 -- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts)(doc-sync(文档同步门禁)的一个成员)检查结构,断言已提交的 INDEX.md 与新鲜渲染结果逐字节一致(`gen-cordis-catalog`/`verify-cordis-catalog` 模式),并拒绝在策展 README 中出现索引格式的行。新鲜度检查涵盖了索引完整性检查:从磁盘生成的表格在定义上就是完整的、标题正确的。 +- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts)(`doc-sync`(文档同步门禁)的一个成员)检查结构,断言已提交的 INDEX.md 与新鲜渲染结果逐字节一致(`gen-cordis-catalog`/`verify-cordis-catalog` 模式),并拒绝在策展 README 中出现索引格式的行。新鲜度检查涵盖了索引完整性检查:从磁盘生成的表格在定义上就是完整的、标题正确的。 添加、移动或删除一个 RFC 只需编辑 RFC 文件本身并运行生成器;分类 RFC 的「已否决替代方案」记录中带有替代关系的交叉链接。 diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml index 5851ab8d1c..7aba38dbc9 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-persistence-log-catalog.md: f8f831ca0470cf3b5c7634550115e67f7deac840 -2026-07-04-persistence-log-catalog.zh.md: 1daa76e2b23484ad6434f6a55482672abf456eb7 +2026-07-04-persistence-log-catalog.md: d92666fc8bafcf42542940fe399d9a3ece20c173 +2026-07-04-persistence-log-catalog.zh.md: a3524b251522050567be240f28944872e725a24e diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md index f8f831ca04..d92666fc8b 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -1,9 +1,9 @@ # RFC: Generated persistence log event catalog -English | [中文](2026-07-04-persistence-log-catalog.zh.md) - Status: implemented +English | [中文](2026-07-04-persistence-log-catalog.zh.md) + ## Problem `SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event and payload; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output. diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md index 1daa76e2b2..a3524b2515 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC:生成式持久化日志事件目录 +# RFC: 生成式持久化日志事件目录 Status: implemented diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml index a5e22d0911..188ebf1019 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-uniform-rfc-format.md: f67c61c0b627950cf07e7d57672324308a9462ec -2026-07-05-uniform-rfc-format.zh.md: b175c2d5b7537e793a61c95b6524d08bc4216384 +2026-07-05-uniform-rfc-format.md: 9688ad6566e88372508bbfd8d032ab6d02568716 +2026-07-05-uniform-rfc-format.zh.md: eab2505e64b813f698b4356bc52a796cbd66b948 diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md index f67c61c0b6..9688ad6566 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md @@ -1,9 +1,9 @@ # RFC: One gated in-file format for RFCs -English | [中文](2026-07-05-uniform-rfc-format.zh.md) - Status: implemented +English | [中文](2026-07-05-uniform-rfc-format.zh.md) + ## Problem RFC paths encoded lifecycle and class, but file contents still mixed headings, status formats, ADR and proposal templates, and proposal-era sections in implemented records. Authors copied whichever neighbor they found, and lifecycle moves could skip the required rewrite because no gate enforced an in-file contract. diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md index b175c2d5b7..eab2505e64 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.zh.md @@ -1,16 +1,16 @@ -# RFC:RFC 的统一受门禁约束的文件内格式 - -[English](2026-07-05-uniform-rfc-format.md) | 中文 +# RFC: RFC 的统一受门禁约束的文件内格式 Status: implemented +[English](2026-07-05-uniform-rfc-format.md) | 中文 + ## 问题 RFC 的路径已经编码了生命周期和分类,但文件内容仍然混杂着不同的标题风格、状态格式、ADR 与 proposal 模板,以及已实现记录中残留的 proposal 时期的章节。作者随手复制找到的任何邻近文件,生命周期迁移时可以跳过必要的改写,因为没有门禁强制执行文件内契约。 ## 决策 -[README.md § The file format](../../README.md#the-file-format) 即文件内契约:头部块(`# RFC: <title>` 加上无日期、与所在文件夹一致的 `Status:` 枚举,唯一的正文内容是 rejection reason);按生命周期区分的正文骨架(所有阶段都以 `Problem` 开头;`proposed/` 中为 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 中为现在时态的 `Decision`/`Consequences` 且禁止 proposal 时期的标题;`rejected/` 中冻结 proposal 形态);必须包含 `Alternatives considered` 章节;以及规范的章节词汇表,其间的自定义技术章节保持自由形式。`pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts))作为 doc-sync(文档同步门禁)的一环强制执行每条机械化条款,因此生命周期迁移时跳过改写现在会让 CI 失败,而不是依赖评审者的记忆。 +[README.md § The file format](../../README.md#the-file-format) 即文件内契约:头部块(`# RFC: <title>` 加上无日期、与所在文件夹一致的 `Status:` 枚举,唯一的正文内容是 rejection reason);按生命周期区分的正文骨架(所有阶段都以 `Problem` 开头;`proposed/` 中为 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 中为现在时态的 `Decision`/`Consequences` 且禁止 proposal 时期的标题;`rejected/` 中冻结 proposal 形态);必须包含 `Alternatives considered` 章节;以及规范的章节词汇表,其间的自定义技术章节保持自由形式。`pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts))作为 `doc-sync`(文档同步门禁)的一环强制执行每条机械化条款,因此生命周期迁移时跳过改写现在会让 CI 失败,而不是依赖评审者的记忆。 整个语料库在定义格式的同一个变更中完成了规范化,这是预发布阶段的立场:没有过渡期,不容忍双格式并存。唯一的祖父条款针对内容而非格式:替代方案是记录下来的,不是凭空编造的;因此如果一篇格式定义前的 RFC 的替代方案无法从记录中重建,它会携带 `rfc-format: alternatives-not-recorded` 这条精确注释,门禁仅对日期早于本 RFC 的文件接受该注释。 diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml index b79ff4cf97..ed24da934e 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-export-surface-jsdoc-gate.md: fd255cc6212d7f6f919ad23f999fa68b01478a73 -2026-07-06-export-surface-jsdoc-gate.zh.md: 796b5bb8f3a2a890986c73511bb63e167c831a5f +2026-07-06-export-surface-jsdoc-gate.md: c2543fe50320da70c9d75bb4a16df3b110b50c47 +2026-07-06-export-surface-jsdoc-gate.zh.md: 98cb057cc1a661d2f51b8215609fc280d549c103 diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index fd255cc621..c2543fe503 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -1,9 +1,9 @@ # RFC: Export-surface JSDoc gate -English | [中文](2026-07-06-export-surface-jsdoc-gate.zh.md) - Status: implemented +English | [中文](2026-07-06-export-surface-jsdoc-gate.zh.md) + ## Problem The [cordis JSDoc completeness gate](2026-07-04-cordis-jsdoc-completeness-gate.md) made undocumented parameters and results impossible on the cordis surface — `interface Events` members and `ctx.<key>` service classes — but that surface is a fraction of what a plugin author imports. The AGENTS.md rule "every export (and non-obvious method) has a JSDoc explaining semantics" stayed prose-checkable only by review everywhere else, and nothing at all asked for `@param`/`@returns` on ordinary exported functions. A survey at adoption found 203 under-documented module-level exports across 34 packages: seam-adjacent helpers (`runBash`, `readForEdit`, `htmlToMarkdown`), format codecs, whole undocumented interfaces and type aliases — exactly the names an IDE consumer hovers. diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md index 796b5bb8f3..98cb057cc1 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md @@ -1,9 +1,9 @@ -# RFC:导出表面 JSDoc 门禁 - -[English](2026-07-06-export-surface-jsdoc-gate.md) | 中文 +# RFC: 导出表面 JSDoc 门禁 Status: implemented +[English](2026-07-06-export-surface-jsdoc-gate.md) | 中文 + ## 问题 [Cordis JSDoc 完整性门禁](2026-07-04-cordis-jsdoc-completeness-gate.md)使得 Cordis 表面上的参数和返回值不可能缺少文档——`interface Events` 成员和 `ctx.<key>` 服务类——但这只是插件作者所导入内容的一小部分。AGENTS.md 中的规则「每个导出(以及非显而易见的方法)都必须有解释语义的 JSDoc」在其他地方只能靠评审以行文方式检查,而且没有任何机制要求普通导出函数带 `@param`/`@returns`。采纳时的一次调查发现 34 个包(package)中有 203 个文档不完整的模块级导出:seam 相关辅助函数(`runBash`、`readForEdit`、`htmlToMarkdown`)、格式编解码器、完全无文档的接口和类型别名——恰恰是 IDE 消费方悬停查看的那些名称。 @@ -19,7 +19,7 @@ Status: implemented - 导出类需要类级别的描述文字;公开方法(包括静态方法——可通过导出名称访问)遵循函数契约;公开属性和访问器需要描述文字(get/set 对由 getter 覆盖)。重载实现体免检——签名承载文档。 - 导出接口、类型别名和枚举需要声明级别的描述文字;成员级别的强制有意推迟(承载关键成员契约的 seam 服务类已在 Cordis 门禁之下)。 - 导出命名空间递归检查(在 ambient `declare` 命名空间内,每个成员隐式导出);命名空间本身仅在不与同名的已文档化声明合并时才需要描述文字(Config-namespace 惯用法只需文档化插件一次)。 -- `declare module`/`declare global` 体和 `export … from` 重导出语句被跳过:augmentation 不是包的导出,重导出的定义在其定义处检查。`export import X = N.member` 别名需要文档化**自身**——其目标可能是遍历不会访问的非导出命名空间成员——且门禁仅支持纯描述文字的目标类型:可调用、类或命名空间目标携带别名描述文字无法承载的签名/成员契约,门禁会拒绝并要求直接导出该声明。 +- `declare module`/`declare global` 体和 `export … from` 重导出语句被跳过:augmentation 不是包的导出,重导出的定义在其定义处检查。`export import X = N.member` 别名需要文档化自身——其目标可能是遍历不会访问的非导出命名空间成员——且门禁仅支持纯描述文字的目标类型:可调用、类或命名空间目标携带别名描述文字无法承载的签名/成员契约,门禁会拒绝并要求直接导出该声明。 - 其余情况按封闭原则失败:`export =` 直接拒绝;基类从未命名的参数即使作为绑定模式仍需 `@param`;dispatch 不识别的导出语句类型本身就是违规——没有任何导出形式能因遗漏而免检。 三类豁免避免门禁要求样板代码,精神与 Cordis 门禁的 `this`/`next` 豁免一致(为已豁免的名称编写文档是允许的;只有缺失才不被检查): @@ -38,7 +38,7 @@ Status: implemented ## 后果 -- 新增导出不能在无文档的情况下合入:`verify-export-jsdoc` 使 `doc-sync` 失败,而 pre-push 和 CI 已运行 `doc-sync`。采纳时发现的 203 处缺口在同一个变更中补齐,门禁以绿色状态落地。 +- 新增导出不能在无文档的情况下合入:`verify-export-jsdoc` 使 `doc-sync` 失败,而 pre-push 和 CI 已运行该门禁。采纳时发现的 203 处缺口在同一个变更中补齐,门禁以绿色状态落地。 - 导出函数必须标注返回类型(采纳时已全面满足,现在成为门禁依赖),并在 `@param` 需要命名参数时使用标识符参数。 - seam 文档是权威的:实现从其继承链继承文档,值得保留在实现上的行为说明是补充,而非必需。 - 门禁构建一个 `ts.Program`(约 6 秒)——唯一需要类型解析的文档门禁;在已编译文档片段的 `doc-sync` 内可以接受。 diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml index ccfbb1cc49..18aecd24ad 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-generated-config-catalog.md: 50ba0dc70b0d8ea54a4f93911f3a087806774626 -2026-07-06-generated-config-catalog.zh.md: 87a861bab394ec268fb3c870e848db37fa4d6fcf +2026-07-06-generated-config-catalog.md: 77c2007d4b5cd28efcfe391c2de712db3b5c107e +2026-07-06-generated-config-catalog.zh.md: 4d9b190b770432111287cedee5992d5822197feb diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md index 50ba0dc70b..77c2007d4b 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md @@ -1,9 +1,9 @@ # RFC: Generated plugin config catalog -English | [中文](2026-07-06-generated-config-catalog.zh.md) - Status: implemented +English | [中文](2026-07-06-generated-config-catalog.zh.md) + ## Problem The repository had no source-backed reference for plugin configuration. Package READMEs documented fields inconsistently, did not enumerate which packages are loadable, and did not verify that runtime schemas agree with declared config types. diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md index 87a861bab3..4d9b190b77 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC:生成式插件配置目录 +# RFC: 生成式插件配置目录 Status: implemented diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index 815cf98e72..e324b52b3f 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-node-engine-floor.md: 561b6b4a124b6eaa8e2ba0756a835e35519b30b8 -2026-07-06-node-engine-floor.zh.md: 21af2da919754b1ae4667b46ef2f65c14a279b49 +2026-07-06-node-engine-floor.md: f7764c408374a329c9390635e1b92ae9a4fd9dee +2026-07-06-node-engine-floor.zh.md: c46bf5168007b97878319e5f333b5bb1ab1d8f2a diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md index 561b6b4a12..f7764c4083 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md @@ -1,9 +1,9 @@ # RFC: Raise the Node LTS engine floor to 22.19 -English | [中文](2026-07-06-node-engine-floor.zh.md) - Status: implemented +English | [中文](2026-07-06-node-engine-floor.zh.md) + ## Problem The Node 22 branch of the root `engines.node` range is a contract for the installed workspace, not only for the runtime APIs the harness source calls directly. It must be no lower than package `engines.node` declarations for dependencies the workspace installs on that branch; otherwise `pnpm install --engine-strict` fails at an advertised LTS version, and non-strict installs run outside a dependency's supported runtime. diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md index 21af2da919..c46bf51680 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -1,9 +1,9 @@ -# RFC:将 Node LTS 引擎下限提升至 22.19 - -[English](2026-07-06-node-engine-floor.md) | 中文 +# RFC: 将 Node LTS 引擎下限提升至 22.19 Status: implemented +[English](2026-07-06-node-engine-floor.md) | 中文 + ## 问题 根 `engines.node` 范围中的 Node 22 分支是对已安装工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。它不得低于工作区在该分支上安装的依赖所声明的 package `engines.node`;否则 `pnpm install --engine-strict` 会在一个已宣传的 LTS 版本上失败,而非严格模式的安装则会在依赖所支持的运行时范围之外运行。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml index 7abfb8a8ed..40132b2b97 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-parallel-github-ci-gates.md: 890adf58ac8a20a39806aa028d035cb253d5a4f1 -2026-07-06-parallel-github-ci-gates.zh.md: 47562ed6a83b650a3275b4045c39c4de6d2ecac6 +2026-07-06-parallel-github-ci-gates.md: 6a9bd339304c8efac7a29bdbe745270ab9661396 +2026-07-06-parallel-github-ci-gates.zh.md: 24fdf27822aac7640fbeaf602fa8dd84277083fa diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md index 890adf58ac..6a9bd33930 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -1,9 +1,9 @@ # RFC: Parallel GitHub CI gates -English | [中文](2026-07-06-parallel-github-ci-gates.zh.md) - Status: implemented +English | [中文](2026-07-06-parallel-github-ci-gates.zh.md) + ## Problem The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every leaf gate into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck. diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md index 47562ed6a8..24fdf27822 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md @@ -1,9 +1,9 @@ -# RFC:并行 GitHub CI 门禁 - -[English](2026-07-06-parallel-github-ci-gates.md) | 中文 +# RFC: 并行 GitHub CI 门禁 Status: implemented +[English](2026-07-06-parallel-github-ci-gates.md) | 中文 + ## 问题 keyless GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照回放、构建、包发布卫生检查、demo 冒烟测试与 built-bin 冒烟测试各自因不同原因失败,彼此不需要对方的运行时状态。将它们串成一条有序命令链,工作流的挂钟时间等于所有门禁之和;而把每个叶子门禁拆成独立的 GitHub job,则会重复 checkout、Node 设置、pnpm restore 和 install 工作,直到编排开销本身成为瓶颈。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index 6475a65971..e3e1c0d234 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-parallel-pre-push-gates.md: 8a8813f2f3f6726ab2ab028757406d366ceb8b6d -2026-07-06-parallel-pre-push-gates.zh.md: 6ee3a8003853092d75cda9908bfae13ee0d4c7a2 +2026-07-06-parallel-pre-push-gates.md: cf032f8dfedd88a7d6be87999fd9786a9efbb2e8 +2026-07-06-parallel-pre-push-gates.zh.md: 776faf1f46c6490f1935727612225414dd258aa3 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md index 8a8813f2f3..cf032f8dfe 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -1,9 +1,9 @@ # RFC: Parallel pre-push gates -English | [中文](2026-07-06-parallel-pre-push-gates.zh.md) - Status: implemented +English | [中文](2026-07-06-parallel-pre-push-gates.zh.md) + ## Problem The pre-push hook is the last local checkpoint before a branch leaves the machine, so its wall clock directly shapes whether contributors keep it enabled and trust its signal. Lefthook already runs top-level jobs in parallel, but aggregate jobs such as `pnpm run hygiene` and `pnpm run doc-sync` hide long sequential chains inside one job. The hook can therefore be configured as parallel while still waiting on serial subcommands whose members are independent. diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 6ee3a80038..776faf1f46 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -1,9 +1,9 @@ -# RFC:并行 pre-push 门禁 - -[English](2026-07-06-parallel-pre-push-gates.md) | 中文 +# RFC: 并行 pre-push 门禁 Status: implemented +[English](2026-07-06-parallel-pre-push-gates.md) | 中文 + ## 问题 pre-push 钩子是分支离开本地机器前的最后一道检查点,因此它的挂钟时间直接影响贡献者是否愿意保持启用并信任其信号。Lefthook 已经能并行运行顶层 job,但 `pnpm run hygiene` 和 `pnpm run doc-sync` 等聚合 job 在单个 job 内部隐藏了长串的顺序执行链。钩子因此可能在配置上看似并行,实际仍在等待那些成员彼此独立却串行执行的子命令。 diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml index 81355776bb..69cfd7e0db 100644 --- a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-readme-known-limitations-gate.md: b7f45421bf0d4d50ec1a19941934e782f52e7926 -2026-07-10-readme-known-limitations-gate.zh.md: dc023ac43890d8aaaefaece2e592001629e2a74e +2026-07-10-readme-known-limitations-gate.md: 1aad1d40285f295833c2b1bd7d5c2e71bb780f34 +2026-07-10-readme-known-limitations-gate.zh.md: 4b3904fac3f980f9d005907775618cd28778a6fe diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md index b7f45421bf..1aad1d4028 100644 --- a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md @@ -1,9 +1,9 @@ # RFC: A gated Known-Limitations section in every package README -English | [中文](2026-07-10-readme-known-limitations-gate.zh.md) - Status: implemented +English | [中文](2026-07-10-readme-known-limitations-gate.zh.md) + ## Problem The [documentation standard](../../../AGENTS.md) assigns limitations to package READMEs. Without a shared shape, an omitted section cannot distinguish an audited absence from forgotten documentation, and variant headings prevent a repository-wide search. diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md index dc023ac438..4b3904fac3 100644 --- a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md @@ -1,9 +1,9 @@ -# RFC:在每个 package README 中设置受门禁保护的 Known Limitations 章节 - -[English](2026-07-10-readme-known-limitations-gate.md) | 中文 +# RFC: 在每个 package README 中设置受门禁保护的 Known Limitations 章节 Status: implemented +[English](2026-07-10-readme-known-limitations-gate.md) | 中文 + ## 问题 [文档标准](../../../AGENTS.md)将限制事项指定在 package README 中记录。如果没有统一的格式,缺失的章节无法区分「经审计确认无此内容」与「忘了写文档」,而标题写法不一致也会妨碍全仓库搜索。 diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml index 9e9b379eb9..4a0af0834e 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-package-model-experience-contract.md: 036efbc9510d6d0ae9e3c52a5ba8f39647adc4c9 -2026-07-12-package-model-experience-contract.zh.md: b1efa712bc4f6fa7b23c0afe965e56eabf068d97 +2026-07-12-package-model-experience-contract.md: 28a1c75ca6f451b4ca2f6da6abf36f72c5d43b31 +2026-07-12-package-model-experience-contract.zh.md: 77fa7a67847ed186b65db57b6dda378a93b94d92 diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md index 036efbc951..28a1c75ca6 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md @@ -1,9 +1,9 @@ # RFC: Package Model Experience contract -English | [中文](2026-07-12-package-model-experience-contract.zh.md) - Status: implemented +English | [中文](2026-07-12-package-model-experience-contract.zh.md) + ## Problem A package README can explain APIs and runtime mechanics without answering the question that dominates an agent harness's behavior and cost: what from this package reaches a model request, under which conditions, and how long those tokens remain. The omission is especially hard to audit in a plugin architecture. A consumer may turn a backend result into a tool message, a policy plugin may replace success with an error, compaction may remove old history, and an agent-scoped registration may change one agent's prompt or schemas while leaving every other agent unchanged. Reading only the nominally model-facing packages therefore misses real context effects, while reading source across every dependency is too expensive for routine review. diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md index b1efa712bc..77fa7a6784 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.zh.md @@ -1,9 +1,9 @@ -# RFC:Package Model Experience 契约 - -[English](2026-07-12-package-model-experience-contract.md) | 中文 +# RFC: Package Model Experience 契约 Status: implemented +[English](2026-07-12-package-model-experience-contract.md) | 中文 + ## 问题 一个 package(包)的 README 可以解释 API 和运行时机制,却不回答那个主导 agent harness(智能体框架)行为与成本的问题:本 package 中有什么内容会进入模型请求、在什么条件下进入、以及这些 token 会保留多久。在插件架构中,这一缺失尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能把成功替换为错误,上下文压缩(context compaction)可能移除旧历史,agent 作用域的注册可能改变某个 agent 的提示词或 schema 而其他 agent 不受影响。因此,只阅读名义上面向模型的 package 会遗漏真实的上下文影响,而跨所有依赖阅读源码对日常评审来说又太昂贵。 @@ -16,7 +16,7 @@ Status: implemented 没有模型上下文效应的 package,或其路径完全由另一个 package 渲染的 package,使用验证器审计过的单句形式:`None, as ` 或 `Indirectly, through `。纯传输和无密钥的测试支持 package 在不创建模型绑定内容时使用 none 形式。提供方后端即使对数据进行上限或过滤,也使用 indirect 形式;组装 bundle 在命名子 package 拥有全部效应时同样使用 indirect 形式。这些句子定位贡献所在,而不重述消费方的内容。结构化章节同样只记录 package 自身拥有的输入、变换和差异。 -`verify-package-readme-model-experience` 发现 package manifest(元数据清单)并验证三种分类、规范的末尾章节顺序、必填字段、具体字面量证据、嵌套逐字块和锚定的工具目录链接。它在 doc-sync(文档同步门禁)和并行门禁运行器中执行。覆盖面、链接相关性和事实准确性仍由评审把关。 +`verify-package-readme-model-experience` 发现 package manifest(元数据清单)并验证三种分类、规范的末尾章节顺序、必填字段、具体字面量证据、嵌套逐字块和锚定的工具目录链接。它在 `doc-sync`(文档同步门禁)和并行门禁运行器中执行。覆盖面、链接相关性和事实准确性仍由评审把关。 ## 曾考虑的替代方案 diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index 7d48003ed6..d94f825076 100644 --- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-19-drop-mutable-session-summary.md: 0d790191906a9128ad12d40526fde3b9f8fa939f -2026-06-19-drop-mutable-session-summary.zh.md: 6b234eb0dbdf764223e078519c810216c28603be +2026-06-19-drop-mutable-session-summary.md: 0f005a78045869d62eb141c9bce8af037671b687 +2026-06-19-drop-mutable-session-summary.zh.md: a33b057c2177258e0e3e2c0c3f78176835acb070 diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md index 0d79019190..0f005a7804 100644 --- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md @@ -1,9 +1,9 @@ # RFC: Drop the mutable session summary -English | [中文](2026-06-19-drop-mutable-session-summary.zh.md) - Status: implemented +English | [中文](2026-06-19-drop-mutable-session-summary.zh.md) + ## Problem The [session-persistence seam](../architecture/2026-06-14-session-persistence.md) split a session's out-of-log metadata into two types owned by `dsh-session`: an immutable `SessionHeader` (`version`, `id`, `createdAt`, `cwd?`, `parentSession?`) written once at creation, and a mutable `SessionSummary` (`updatedAt`, `title?`, `firstPrompt?`) "updateable without touching the append-only log". Their union was `SessionMeta = SessionHeader & SessionSummary`, and the abstract `SessionPersistence` service carried a seventh method — `update(id, summary)` — for rewriting the summary. Each backend implemented the mutable store its own way: JSONL wrote a separate atomic `.summary.json` **sidecar** beside the log (temp-write + rename, best-effort), SQLite kept `updated_at`/`title`/`first_prompt` **columns** bumped inside the append transaction. diff --git a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index 6b234eb0db..a33b057c21 100644 --- a/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -1,4 +1,4 @@ -# RFC:移除可变的会话摘要 +# RFC: 移除可变的会话摘要 Status: implemented @@ -12,7 +12,7 @@ Status: implemented - `SessionPersistence.update()` **零个生产调用方**(所有 `.update(` 匹配都是 `createHash().update()` 或测试代码)。 - `firstPrompt` 在生产代码中**从未被读取**。 -- `title` 确实在 ACP 桥接层被读取过,但读的是工具调用的 **presenter**(`present.title`),从未读取存储的会话元数据。 +- `title` *确实*在 ACP 桥接层被读取过,但读的是工具调用的 **presenter**(`present.title`),从未读取存储的会话元数据。 - `updatedAt` **没有消费方**:`list()` 唯一的生产调用方读取的是 `meta.cwd`(`SessionHeader` 字段),用于在 `session/load` 时校验工作区;恢复会话读取的是 `createdAt`/`cwd`/`parentSession`——全是 header 字段。 - 决定性的一点:活跃的 `Session.header` 类型本来就是 `SessionHeader` 而非 `SessionMeta`——摘要从未存在于活跃会话对象上;它只存在于持久化层,除了自身的契约测试外无人写入、无人读取。 @@ -20,13 +20,13 @@ Status: implemented 彻底删除可变的会话摘要。`SessionSummary` 与 `SessionMeta` 这个名称一并移除;后端存储和返回的元数据仅为 `SessionHeader`。`SessionPersistence.update()` 从抽象服务和所有后端中移除。JSONL 去掉整套伴随文件机制(`writeSidecar`/`readSidecar`/`touchSummary`/`removeSidecars`/`sidecarPath` 以及 load/list 的覆盖逻辑);SQLite 去掉 `updated_at`/`title`/`first_prompt` 列以及每次追加时的 `updated_at` 更新,其 `SCHEMA_VERSION` 从 `1 → 2`。 -摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一不可派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 +摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一*不可*派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 将此记录为决策,原因有三:**持久性**(它收窄了一个公开服务契约和跨两个后端的磁盘格式)、**争议性**(摘要是有意的前瞻性设计,而非意外产物)、**意外性**(未来读者看到 `SessionHeader` 而原始 RFC 描述的是 `SessionMeta`,否则会疑惑摘要为何消失)。它还为 [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md) 扫清了障碍:没有可变摘要后,协调器的钩子接口无需 `updateSummary` 钩子,JSONL 伴随文件与 SQLite 列之间的持久性分歧也随之消失,两个后端的写入路径得以统一。 ## 无需迁移 -这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「Pre-release stance: foundation over blast radius」一节),因此没有需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论更旧还是更新,因此陈旧的 v1 数据库会被干净地拒绝,而非在新列集下被半读取。新建数据库写入当前版本号;这是唯一需要正常工作的路径。 +这是未发布的软件(见[根 AGENTS.md](../../../../AGENTS.md)「Pre-release stance: foundation over blast radius」一节),因此没有需要保留的磁盘数据库或日志。SQLite 不迁移 v1 数据库:`openDatabase` 守卫现在拒绝任何非当前版本的磁盘 `user_version`(`onDisk !== 0 && onDisk !== SCHEMA_VERSION`),无论更旧*还是*更新,因此陈旧的 v1 数据库会被干净地拒绝,而非在新列集下被半读取。新建数据库写入当前版本号;这是唯一需要正常工作的路径。 ## 后果 diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml index 807d67d2fc..784b07f47b 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-collapse-trace-only-session-events.md: 9156c2ab356b1c46758d9d2047d491cd1952cbc4 -2026-06-20-collapse-trace-only-session-events.zh.md: c4555f3a772096fc36de968d5d3085f5a2e879f3 +2026-06-20-collapse-trace-only-session-events.md: c446f43d887088fcf562305fcc2dad37465fb124 +2026-06-20-collapse-trace-only-session-events.zh.md: eda7f738abbaced393f2588867f9dcd8e86e2386 diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index 9156c2ab35..c446f43d88 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -1,9 +1,9 @@ # RFC: Fold trace-only session facts into load-bearing events -English | [中文](2026-06-20-collapse-trace-only-session-events.zh.md) - Status: implemented +English | [中文](2026-06-20-collapse-trace-only-session-events.zh.md) + ## Problem The session event vocabulary includes first-class events that are not part of replayable conversation history and have little or no production consumption. `usage` is already present as a model stream chunk before the loop also appends a separate `usage` event. `error` duplicates the `turn/end { kind: 'error', message, code }` reason for loop failures; ACP settlement reads the turn-end reason, ACP rendering ignores the `error` event, and `deriveMessages()` skips it. diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md index c4555f3a77..eda7f738ab 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -1,4 +1,4 @@ -# RFC:将仅用于追踪的会话事实折叠进承载性事件 +# RFC: 将仅用于追踪的会话事实折叠进承载性事件 Status: implemented diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml index dbb62e578d..7534bcc2da 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-unconsumed-llm-adapter-change-event.md: efe90c0197671ef4385ce517540b4b238962c3b5 -2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: b26610cfe273820112113c73b9313557cd78262c +2026-06-20-drop-unconsumed-llm-adapter-change-event.md: 657e5f08c02e1e03eedeccf8a30b8bc851201615 +2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: fbc2248c8357dbff3f5f5008647c14c23a66d5b0 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index efe90c0197..657e5f08c0 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -1,9 +1,9 @@ # RFC: Drop the unconsumed `llm/adapter-change` event -English | [中文](2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md) - Status: implemented +English | [中文](2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md) + ## Problem `LlmService.registerAdapter()` emits `llm/adapter-change` on registration and disposal ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)). Grepping `llm/adapter-change` across `packages/*/src` and `examples/*/src` finds only the declaration, emit sites, docs, and tests; no production listener subscribes to it. diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md index b26610cfe2..fbc2248c83 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md @@ -1,4 +1,4 @@ -# RFC:移除未被消费的 `llm/adapter-change` 事件 +# RFC: 移除未被消费的 `llm/adapter-change` 事件 Status: implemented diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml index 0d895a2b35..f9986e5608 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: c8999dd0e19b2c8eaff854c8ff544bae2fc068b6 -2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: de297a8cb64d9002fce2c857fd3caa5f2b25f43a +2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: ead3a8c094b0fc0b4bd01671bd4a1165dd555ea2 +2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 16704b511151c329622e58c3b5f6b2cff330f366 diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index c8999dd0e1..ead3a8c094 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -1,9 +1,9 @@ # RFC: Drop unconsumed assembled LLM convenience surfaces -English | [中文](2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md) - Status: implemented +English | [中文](2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md) + ## Problem `LlmService` ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)) exposes three call surfaces over a model: diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md index de297a8cb6..16704b5111 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md @@ -1,4 +1,4 @@ -# RFC:移除未被消费的 LLM 组装便捷接口 +# RFC: 移除未被消费的 LLM 组装便捷接口 Status: implemented diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml index e995e57540..b777318d66 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-prune-dead-seam-methods.md: cb3eb576dbae209ddccbea7f42a80ef09f842887 -2026-06-20-prune-dead-seam-methods.zh.md: 988da3c44d8860d89f090f0ea9e2af49ae9007dd +2026-06-20-prune-dead-seam-methods.md: fc656f4fc46837a75fa6c34c2de18cffde954ef3 +2026-06-20-prune-dead-seam-methods.zh.md: 2b8b2afea97aa28d32d55a9def217a593e9dbc7d diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index cb3eb576db..fc656f4fc4 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -1,9 +1,9 @@ # RFC: Prune dead methods from the persistence seam -English | [中文](2026-06-20-prune-dead-seam-methods.zh.md) - Status: implemented +English | [中文](2026-06-20-prune-dead-seam-methods.zh.md) + > **Implementation note:** Only `SessionPersistence.has()` and `.delete()` were removed. `BashExecutor.get()` and `.list()` remain because removing their one-line lookup surface required substantially more completion-tracking machinery in consumers. Their id branding is covered by the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md). ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md index 988da3c44d..2b8b2afea9 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md @@ -1,9 +1,9 @@ -# RFC:从 persistence seam 中移除无用方法 - -[English](2026-06-20-prune-dead-seam-methods.md) | 中文 +# RFC: 从 persistence seam 中移除无用方法 Status: implemented +[English](2026-06-20-prune-dead-seam-methods.md) | 中文 + > **实现说明:** 最终只移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 保留,因为移除它们的单行查询接口需要在消费方引入大量额外的完成状态追踪机制。它们的 id 品牌化由 [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) 覆盖。 ## 问题 diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index 68ba79e73a..eec7c0f05d 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-public-agent-stop-surface.md: 8c371911616b0a156156355b2ca15d795cfd21e5 -2026-06-20-public-agent-stop-surface.zh.md: bbd61fa1738fda64ec5e068dae84062163937c1a +2026-06-20-public-agent-stop-surface.md: d34f21be66892e261f1435070aaf9b478c8dc7cd +2026-06-20-public-agent-stop-surface.zh.md: a510d39044048637c2462fd1d97eb3474931761a diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index 8c37191161..d34f21be66 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -1,9 +1,9 @@ # RFC: Keep one public stop primitive -English | [中文](2026-06-20-public-agent-stop-surface.zh.md) - Status: implemented +English | [中文](2026-06-20-public-agent-stop-surface.zh.md) + > **Implementation note:** Only `abort()` was removed. `whenIdle()` remains because it is the public quiescence signal and safely handles waiter settlement and replacement-turn races; consumers should not reconstruct that behavior from status transitions. ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index bbd61fa173..a510d39044 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -1,4 +1,4 @@ -# RFC:保留单一公开停止原语 +# RFC: 保留单一公开停止原语 Status: implemented diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index ec11d8ea9a..16911f7c70 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-remove-agent-boundary-mirror-events.md: 46b5e43951885915d4c3dd3f867ced6c31d32035 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: 15be07f43997b1d899f0297d311c3ad83f088ee0 +2026-06-20-remove-agent-boundary-mirror-events.md: 0ea8512ca6b66b2e8ab8cf2746bdc65d79caa9d4 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 30632bf223cb41c18f62c18a544ff42f70af1136 diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index 46b5e43951..0ea8512ca6 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -1,9 +1,9 @@ # RFC: Stop mirroring durable boundaries as agent events -English | [中文](2026-06-20-remove-agent-boundary-mirror-events.zh.md) - Status: implemented +English | [中文](2026-06-20-remove-agent-boundary-mirror-events.zh.md) + ## Problem The loop exposed durable turn and step boundaries through both the replayable `SessionEvent` log and live `agent/*` mirrors. Consumers had to choose between two sources for the same fact and reconcile their timing. ACP and persistence already used the log; the stdio UI was the only remaining mirror consumer and already rendered tool calls and results from `session/event`. diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index 15be07f439..30632bf223 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -1,9 +1,9 @@ -# RFC:停止将持久化边界镜像为 agent 事件 - -[English](2026-06-20-remove-agent-boundary-mirror-events.md) | 中文 +# RFC: 停止将持久化边界镜像为 agent 事件 Status: implemented +[English](2026-06-20-remove-agent-boundary-mirror-events.md) | 中文 + ## 问题 agent loop(智能体循环)通过可回放的 `SessionEvent` 日志和实时 `agent/*` 镜像两条路径暴露持久化的轮次与步骤边界。消费方不得不在同一事实的两个来源之间做选择,并协调二者的时序。ACP(Agent Client Protocol)和持久化层已经使用日志;stdio UI 是唯一仍在消费镜像的组件,而它已经从 `session/event` 渲染工具调用和工具结果。 diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml index aecab4f6b5..f1fa61c3c2 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-26-fsspec-style-fs-seam.md: 493af9341177aaaed4cdca03a3c20f326c5c4dac -2026-06-26-fsspec-style-fs-seam.zh.md: 4aa9b260396c22433ea9a4c9af0b4101fa6895e7 +2026-06-26-fsspec-style-fs-seam.md: 7b5e61481eeca9556de58cf3d6f8fe935b2eefb5 +2026-06-26-fsspec-style-fs-seam.zh.md: 72d37c196df99d110ea59c5108dc9de084669c8b diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 493af93411..7b5e61481e 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -1,9 +1,9 @@ # RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin -English | [中文](2026-06-26-fsspec-style-fs-seam.zh.md) - Status: implemented +English | [中文](2026-06-26-fsspec-style-fs-seam.zh.md) + ## Problem The filesystem capability from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) currently makes one abstract `FileSystem` service own two different jobs: diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md index 4aa9b26039..72d37c196d 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md @@ -1,9 +1,9 @@ -# RFC:拆分文件系统 seam——提供方文本变更操作与 `dsh-fs-policy` 插件 - -[English](2026-06-26-fsspec-style-fs-seam.md) | 中文 +# RFC: 拆分文件系统 seam——提供方文本变更操作与 `dsh-fs-policy` 插件 Status: implemented +[English](2026-06-26-fsspec-style-fs-seam.md) | 中文 + ## 问题 [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 中引入的文件系统能力目前让一个抽象的 `FileSystem` 服务承担两类不同的职责: @@ -30,7 +30,7 @@ provider dsh-fs-local local implementation of ctx.fs `dsh-tool-fs` 保持相同的面向模型的 `read`/`write`/`edit` schema。它是执行器:注入 `fs`(不是策略服务)并直接访问 `ctx.fs`,拥有读取窗口化逻辑,并分发 `fs/*` 事件以便 `dsh-fs-policy` 进行门控和记录。 -本 RFC 决定了四层拆分、提供方契约和新鲜度策略。工具↔策略的**耦合方式**随后由[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 细化:`dsh-fs-policy` 是一个门控**插件**,通过 `fs/*` 事件参与而非提供 `ctx.fileContext` 方法服务,因此工具不与它产生方法耦合,读取窗口化与 fs I/O 留在 `dsh-tool-fs` 中。本文描述的是最终落地的事件门控形态;提供方的版本守卫是可选的(省略 = 无条件裸提供方)。 +本 RFC 决定了四层拆分、提供方契约和新鲜度策略。工具↔策略的耦合方式随后由[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 细化:`dsh-fs-policy` 是一个门控插件,通过 `fs/*` 事件参与而非提供 `ctx.fileContext` 方法服务,因此工具不与它产生方法耦合,读取窗口化与 fs I/O 留在 `dsh-tool-fs` 中。本文描述的是最终落地的事件门控形态;提供方的版本守卫是可选的(省略 = 无条件裸提供方)。 ## 提供方契约 @@ -61,9 +61,9 @@ type FsWriteIntent = `writeText` 是原子的临时文件 + rename,带有显式的写入期望。`createIfAbsent` 创建不存在的目标,对已存在的目标以 `FS_NOT_OBSERVED` 拒绝;这是 owner 没有先前读取时使用的路径。`replaceIfVersion` 仅在目标以观测到的版本存在时替换;目标不存在或版本不匹配时抛出 `FS_STALE_VERSION`。 -`editText` 是提供方级别的受保护文本变更。启用守卫时,它首先验证目标仍以 `expected.version` 存在,然后读取当前文本、应用字面替换并原子写入。过期检查必须在字面匹配之前发生,这样基于旧读取的编辑会报告 `FS_STALE_VERSION`,而不是对更新内容进行匹配后报告 `FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`。将此原语保留在提供方 seam 上,保持了后端本地锁定的能力,也让未来的远程后端能够实现原生的 compare-and-edit,而无需策略层拉取整个文件。 +`editText` 是提供方级别的受保护文本变更。启用守卫时,它首先验证目标仍以 `expected.version` 存在,然后读取当前文本、应用字面替换并原子写入。陈旧检查必须在字面匹配之前发生,这样基于旧读取的编辑会报告 `FS_STALE_VERSION`,而不是对更新内容进行匹配后报告 `FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`。将此原语保留在提供方 seam 上,保持了后端本地锁定的能力,也让未来的远程后端能够实现原生的 compare-and-edit,而无需策略层拉取整个文件。 -这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半个层次。UTF-8 解码、二进制/NUL 拒绝、受保护的全文件写入和受保护的字面文本编辑都在提供方内完成,因此策略层从不接触原始字节、不重新实现跨分片解码、也不将过期检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、观测状态存储都不会泄漏下去。 +这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半个层次。UTF-8 解码、二进制/NUL 拒绝、受保护的全文件写入和受保护的字面文本编辑都在提供方内完成,因此策略层从不接触原始字节、不重新实现跨分片解码、也不将陈旧检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、观测状态存储都不会泄漏下去。 从 `dsh-fs` 中删除的内容:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody`,以及观测状态 `WeakMap`。`applyEdit` 被更窄的提供方原语 `editText` 取代,后者的契约是版本守卫的字面文本变更,而非策略层的读取授权。`FS_PARTIAL_OBSERVATION` 错误码也从 `FsErrorCode` 分类体系中移除:新鲜度授权没有 partial/full 之分,因此没有什么能触发它。`FsTargetKey` 和 `FsVersion` 按照既有的 [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md) 成为品牌化的不透明 id。 @@ -76,7 +76,7 @@ type FsWriteIntent = 该插件决定三个 `fs/*` 事件: - `fs/write-intent`——无先前观测 ⇒ `{ kind: 'createIfAbsent' }`(只有新文件可以盲创建);有先前观测 ⇒ `{ kind: 'replaceIfVersion', version: vObserved }`(已有文件仅在自观测以来未变时才替换)。单槽决策;不调用 `next()`。 -- `fs/edit-intent`——要求 owner 有先前观测(否则 `FS_NOT_OBSERVED`);返回 `{ version: vObserved }` 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一赢一过期。 +- `fs/edit-intent`——要求 owner 有先前观测(否则 `FS_NOT_OBSERVED`);返回 `{ version: vObserved }` 作为 CAS 基础。它不实现字面替换——它授权并提供版本,提供方的变更临界区负责应用守卫,因此基于同一观测版本的并发编辑仍然是一赢一陈旧。 - `fs/observed`——在成功的读取/写入/编辑后,为该 owner+target 记录 `{ version }`。同步、仅副作用的 `WeakMap.set`。 该插件不做任何文件系统 I/O:「你是否观测过此文件?」是一次 `WeakMap` 查找,而「你读取的版本是否仍然是当前版本?」在 `ctx.fs.editText`/`writeText` 内部、与执行变更相同的原子锁中决定——插件只提供 `vObserved` 作为基础。 @@ -109,7 +109,7 @@ type FsWriteIntent = ## 验证 -`dsh-fs` 精确暴露 `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`(`stat` 返回 `FsInfo | undefined`,`writeText` 接受 `FsWriteIntent`),已删除的类型/原语不再存在;`dsh-fs-local` 不包含行、视图或 `formatReadBody` 逻辑;面向模型的 schema 保持逐字节不变。测试固定了以下行为:窗口化读取授权对未变文件的后续编辑;基于过期读取的编辑在尝试字面匹配之前报告 `FS_STALE_VERSION`;版本 CAS 行为得以保留;观测契约成立(`read` 工具的读取记录观测状态;直接 `ctx.fs` 读取不记录);`dsh-fs-policy` 具有 HMR(热模块替换)/dispose(资源释放)覆盖率。 +`dsh-fs` 精确暴露 `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`(`stat` 返回 `FsInfo | undefined`,`writeText` 接受 `FsWriteIntent`),已删除的类型/原语不再存在;`dsh-fs-local` 不包含行、视图或 `formatReadBody` 逻辑;面向模型的 schema 保持逐字节不变。测试固定了以下行为:窗口化读取授权对未变文件的后续编辑;基于陈旧读取的编辑在尝试字面匹配之前报告 `FS_STALE_VERSION`;版本 CAS 行为得以保留;观测契约成立(`read` 工具的读取记录观测状态;直接 `ctx.fs` 读取不记录);`dsh-fs-policy` 具有 HMR(热模块替换)/dispose(资源释放)覆盖率。 ## 后续扩展 @@ -117,7 +117,7 @@ type FsWriteIntent = ## 曾考虑的替代方案 -- **字节级 fsspec(`cat`/`open` 返回原始字节)**:否决。该 seam 刻意定位为文本存储,比字节级高半个层次,这样 UTF-8 解码、二进制/NUL 拒绝和受保护的文本变更只在提供方实现一次,策略层从不接触原始字节,也不将过期检查与变更临界区分离。 +- **字节级 fsspec(`cat`/`open` 返回原始字节)**:否决。该 seam 刻意定位为文本存储,比字节级高半个层次,这样 UTF-8 解码、二进制/NUL 拒绝和受保护的文本变更只在提供方实现一次,策略层从不接触原始字节,也不将陈旧检查与变更临界区分离。 - **具体的 `ctx.fileContext` 方法服务**:本 RFC 最初的策略形态;被[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 改造为门控插件,使工具从不与策略产生方法耦合。 - **在提供方保留 `readPage` 和 `full`/`partial` 视图授权**:「取代」一节所逆转的重构前形态。视图完整性不是编辑安全所需的,版本新鲜度才是;而视图规则使超过读取上限的大文件无法编辑。 @@ -126,5 +126,5 @@ type FsWriteIntent = - 新增第四个 fs 包和一个新的插件层。这是有意为之:它是此前推迟的策略层,而非第二个抽象后端 seam。 - 直接使用 `ctx.fs` 会绕过策略:直接 `ctx.fs.readText` 不发出 `fs/observed`,因此在默认策略下,后续 `edit` 会以 `FS_NOT_OBSERVED` 拒绝,直到通过 `read` 工具读取该文件。这一失败是显式且有文档记录的。 - 大文件行窗口化从后端移至 `dsh-tool-fs` 中的 `read` 工具;文本解码和二进制拒绝留在 `ctx.fs.streamText` 中,因此这只是窗口化逻辑的迁移,而非第二套文本 IO 实现。 -- 将 `editText` 保留在提供方 seam 上意味着每个后端都必须实现字面替换契约。这是有意为之:该操作不是纯存储,但过期守卫 + 字面匹配 + 原子重写是必须保持在一起的单元,以确保正确的错误归因和并发行为。该契约应保持窄且仅限文本,以便未来后端可以原生实现或通过全文件重写实现。 +- 将 `editText` 保留在提供方 seam 上意味着每个后端都必须实现字面替换契约。这是有意为之:该操作不是纯存储,但陈旧守卫 + 字面匹配 + 原子重写是必须保持在一起的单元,以确保正确的错误归因和并发行为。该契约应保持窄且仅限文本,以便未来后端可以原生实现或通过全文件重写实现。 - 新鲜度允许在窗口化读取后进行全文件 `write`。这比旧的视图检查更弱,但避免了大文件无法编辑的问题;提示词引导仍然不鼓励盲目的全文件替换。 diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index a9b8b58f67..99d356c3e8 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-remove-stream-chunk-mirror.md: 1ec633b09c8e53ae7145a49061aced191a9aa765 -2026-07-02-remove-stream-chunk-mirror.zh.md: 7cf8a5d056c1bb4193263c58a8e4258173fe8b4e +2026-07-02-remove-stream-chunk-mirror.md: 74843cf49c043d46d44266a7bb0d8c953a749b6f +2026-07-02-remove-stream-chunk-mirror.zh.md: 1b460b4600442535d2572770449ce4dbcc836fe8 diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index 1ec633b09c..74843cf49c 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -1,9 +1,9 @@ # RFC: Stop mirroring the token stream as an agent event -English | [中文](2026-07-02-remove-stream-chunk-mirror.zh.md) - Status: implemented +English | [中文](2026-07-02-remove-stream-chunk-mirror.zh.md) + ## Problem The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/loop.ts` the two sat one line apart: diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index 7cf8a5d056..1b460b4600 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -1,9 +1,9 @@ -# RFC:停止将 token 流镜像为 agent 事件 - -[English](2026-07-02-remove-stream-chunk-mirror.md) | 中文 +# RFC: 停止将 token 流镜像为 agent 事件 Status: implemented +[English](2026-07-02-remove-stream-chunk-mirror.md) | 中文 + ## 问题 agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/loop.ts` 中,二者仅相隔一行: diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml index 9b195da18b..8a31f5ee11 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-drop-image-content-block.md: 8222880b61c225c39a1e132353c5f343fb90cb4b -2026-07-04-drop-image-content-block.zh.md: cb9372e50863193cd579c0bf8de991810db95206 +2026-07-04-drop-image-content-block.md: 145f805cbe335d3b8275bef6a2bb1fcbe1bd3df3 +2026-07-04-drop-image-content-block.zh.md: ba77caeba4cb1fdd3ff95f4cd498c87cdf1aa227 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md index 8222880b61..145f805cbe 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -1,9 +1,9 @@ # RFC: Drop the `image` content block until a path can honor it -English | [中文](2026-07-04-drop-image-content-block.zh.md) - Status: implemented +English | [中文](2026-07-04-drop-image-content-block.zh.md) + ## Problem `ImageBlock` (`packages/llm/llm/src/types.ts`) had no production producer, and every consumer on every path DROPPED it: the deepseek adapter's serializer skipped image blocks (a documented MVP limitation), the pi-ai converter skipped them as unrepresentable, the ACP codec neither advertises image prompt capability nor forwarded image blocks outbound and REJECTS image prompt content inbound, and the compaction estimator charged a flat token constant and rendered `[image]`. An `ImageBlock` constructed then would silently vanish from the wire — the vocabulary advertised a capability no path honored, which is the silent-data-loss shape AGENTS.md's defensive patterns warn against. The only constructors anywhere were tests pinning the skip/drop/estimate branches. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md index cb9372e508..ba77caeba4 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.zh.md @@ -1,12 +1,12 @@ -# RFC:移除 `image` 内容块,直到有路径能真正处理它 - -[English](2026-07-04-drop-image-content-block.md) | 中文 +# RFC: 移除 `image` 内容块,直到有路径能真正处理它 Status: implemented +[English](2026-07-04-drop-image-content-block.md) | 中文 + ## 问题 -`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其**丢弃**:deepseek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP 编解码器既不宣告 image prompt 能力、也不向外转发 image 块,并且会拒绝入站的 image prompt 内容;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 +`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其丢弃:deepseek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP 编解码器既不宣告 image prompt 能力、也不向外转发 image 块,并且会拒绝入站的 image prompt 内容;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 ## 决策 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml index 3dabffc3a4..429e5ae268 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-drop-inert-request-knobs.md: d5484aa5ec64f89ce705ddd0a434c2c4dfbad460 -2026-07-04-drop-inert-request-knobs.zh.md: 9bd13cd024bc0e2ba7795a190d77063c3457fe98 +2026-07-04-drop-inert-request-knobs.md: 86d0dfefe1bdfb0c49b5b9080935441d16dd2223 +2026-07-04-drop-inert-request-knobs.zh.md: f7d969378af7e070ecee82e8ea1a569c61208208 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md index d5484aa5ec..86d0dfefe1 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md @@ -1,9 +1,9 @@ # RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path -English | [中文](2026-07-04-drop-inert-request-knobs.zh.md) - Status: implemented +English | [中文](2026-07-04-drop-inert-request-knobs.zh.md) + ## Problem Two request-contract knobs rode the whole request pipeline, yet neither could do anything: diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md index 9bd13cd024..f7d969378a 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md @@ -1,14 +1,14 @@ -# RFC:移除 `GenerateOptions.prefill` 与 `ToolSchema.strict`——无端到端可用路径的请求旋钮 - -[English](2026-07-04-drop-inert-request-knobs.md) | 中文 +# RFC: 移除 `GenerateOptions.prefill` 与 `ToolSchema.strict`——无端到端可用路径的请求旋钮 Status: implemented +[English](2026-07-04-drop-inert-request-knobs.md) | 中文 + ## 问题 两个请求契约旋钮贯穿了整条请求流水线,却都无法产生任何效果: -- **`prefill`**(`packages/llm/llm/src/types.ts`)没有生产级的 setter:agent loop(智能体循环)组装的是 `model`/`system`/`tools`/`messages` 加 `sessionId`/`signal`,上下文压缩(context compaction)后端只追加 `maxTokens`;而且**两个**适配器都拒绝它:`packages/llm/llm-deepseek/src/serialize.ts` 和 `packages/llm/llm-pi-ai/src/adapter.ts` 各自在 `prefill` 非 undefined 时抛出 `LlmError('UNSUPPORTED')`。该字段的全部可观测行为就是两个 throw,各由一条适配器测试固定。DeepSeek 的 chat-prefix completion 是一个 Beta 功能,运行在两个适配器都未指向的 base URL 上。 +- **`prefill`**(`packages/llm/llm/src/types.ts`)没有生产级的 setter:agent loop(智能体循环)组装的是 `model`/`system`/`tools`/`messages` 加 `sessionId`/`signal`,上下文压缩(context compaction)后端只追加 `maxTokens`;而且两个适配器都拒绝它:`packages/llm/llm-deepseek/src/serialize.ts` 和 `packages/llm/llm-pi-ai/src/adapter.ts` 各自在 `prefill` 非 undefined 时抛出 `LlmError('UNSUPPORTED')`。该字段的全部可观测行为就是两个 throw,各由一条适配器测试固定。DeepSeek 的 chat-prefix completion 是一个 Beta 功能,运行在两个适配器都未指向的 base URL 上。 - **`strict`**(`ToolSchema`,同一文件)穿过了 `DefineToolOptions`/`defineTool`(`packages/core/tools/src/schema.ts`)、注册表的 `schemas()` 允许列表(`packages/core/tools/src/index.ts`)、deepseek 协议格式(wire format)映射(`packages/llm/llm-deepseek/src/serialize.ts`,其 wire-type 注释记录了 strict 模式需要适配器未使用的 `/beta` base URL)、`packages/llm/llm-pi-ai/src/adapter.ts` 中的逐工具 payload 修补逻辑,以及 tool-catalog 渲染器(`scripts/gen-tool-catalog.ts`)中的条件 `Strict:` 行。没有任何已发布的工具设置过它——在所有 `tool-*` 包的 src 和 `examples/` 中执行 `rg` 搜索,`strict:` 的生产者为零;唯一的 setter 出现在 dsh-tools 单元测试中。 两个旋钮在适配器间是对称的,因此移除操作将它们从两个孪生适配器中一并剥离——[孪生适配器设计](../architecture/2026-06-13-twin-llm-adapters.md)不受影响。 @@ -18,13 +18,13 @@ Status: implemented - 从 `GenerateOptions` 中移除 `prefill`,同时移除两个适配器的 UNSUPPORTED 守卫、固定这些 throw 的测试、[core.md](../../../core-data-structures/core.md) 中的粘贴行,以及适配器 README 中记录拒绝行为的行。实操手册(cookbook)中的 UNSUPPORTED 指引([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md))改为泛化表述——你的 provider 无法兑现的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的后果部分将 prefill 记录为「受 producer 门控」而非「已有归属」,依据 [implemented/AGENTS.md](../AGENTS.md)。 - 从 `ToolSchema`、`DefineToolOptions`、`defineTool`、`schemas()` 允许列表、deepseek 序列化分支及其 wire-type 字段,以及 tool-catalog 渲染器的 `Strict:` 行中移除 `strict`。pi-ai 的 payload 修补逻辑简化为对 pi-ai 自身逐工具 strict 默认值的无条件清除(pi-ai 在每个序列化的工具上打 `strict: false`;手写的孪生适配器不发送此字段,因此清除逻辑为保持协议格式对等而保留,由其序列化器测试固定)。setter 测试和 core.md 粘贴行已移除;`GenerateOptions` 与 `ToolSchema` 在 `scripts/type-equiv.manifest.json` 中保留各自的行,因为两个类型只是少了一个字段,本身仍然存在。 -本 RFC 有意**不**触碰 `temperature`、`stop` 或 `maxTokens`:它们在两个适配器中都被端到端地兑现,是 `agent/request` 上请求变更钩子插件的自然首选目标。 +本 RFC 有意不触碰 `temperature`、`stop` 或 `maxTokens`:它们在两个适配器中都被端到端地兑现,是 `agent/request` 上请求变更钩子插件的自然首选目标。 ## 曾考虑的替代方案 ### 为什么不保留? -「显式的 UNSUPPORTED throw 是诚实的契约行为」——但一个在两个孪生适配器中唯一的实现就是拒绝的旋钮,什么也没承诺;删除它反而升级了失败模式:意外的 setter 变成编译错误而非运行时 throw。「Strict schema 遵循是官方文档记载的 provider 功能,且管道完整」——但一个旋钮在有已发布的工具设置它**并且**有端点兑现它之前,不构成产品表面;今天两者都不成立。它们各自随首个真实 producer 回归:`prefill` 随实现了 chat-prefix completion 的适配器(以及对不支持该功能的适配器的明确策略)一起回来;`strict` 随需要它的工具和 beta 端点方案一起回来。 +「显式的 UNSUPPORTED throw 是诚实的契约行为」——但一个在两个孪生适配器中唯一的实现就是拒绝的旋钮,什么也没承诺;删除它反而升级了失败模式:意外的 setter 变成编译错误而非运行时 throw。「Strict schema 遵循是官方文档记载的 provider 功能,且管道完整」——但一个旋钮在有已发布的工具设置它并且有端点兑现它之前,不构成产品表面;今天两者都不成立。它们各自随首个真实 producer 回归:`prefill` 随实现了 chat-prefix completion 的适配器(以及对不支持该功能的适配器的明确策略)一起回来;`strict` 随需要它的工具和 beta 端点方案一起回来。 ## 验证 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml index fb1161ae18..d86354d68a 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-drop-unconsumed-web-observation-surface.md: c48ff5b80c916cd6cc04d6a8339a8555d05b0d40 -2026-07-04-drop-unconsumed-web-observation-surface.zh.md: 4988a5aa77604f528cc23409a3c2290c890a7f5a +2026-07-04-drop-unconsumed-web-observation-surface.md: ba97076eef385cd218517f233ed44f86d3f7eb7a +2026-07-04-drop-unconsumed-web-observation-surface.zh.md: 83c2e786d4edde7b7c94cf2c028b14e20da842b4 diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md index c48ff5b80c..ba97076eef 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md @@ -1,9 +1,9 @@ # RFC: Drop the unconsumed web observation surface — the `providers-change` event and the status methods -English | [中文](2026-07-04-drop-unconsumed-web-observation-surface.zh.md) - Status: implemented +English | [中文](2026-07-04-drop-unconsumed-web-observation-surface.zh.md) + ## Problem `WebService` exposes an observation surface no production code observes: diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md index 4988a5aa77..83c2e786d4 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md @@ -1,4 +1,4 @@ -# RFC:移除未被消费的 web 观测接口——`providers-change` 事件与 status 方法 +# RFC: 移除未被消费的 web 观测接口——`providers-change` 事件与 status 方法 Status: implemented @@ -23,7 +23,7 @@ seam 自身的设计使这两个接口天然没有消费方:工具注册跟随 ### 为什么不保留? -web seam RFC 有意指定了两者——事件作为最小的 HMR 可见性信号,status 方法作为工具的聚合诊断——且未来的 provider 状态面板是可以想象的。但同一 RFC 的其他设计选择使它们失去了消费方:按需派生的选择与基于 enablement 的注册使得没有消费方**能**需要这两者;已交付的工具展示了真实模式(执行并路由结构化错误);漂移的 README 语句表明承诺的消费方从未实现。按 AGENTS.md「RFC 是提案,不是金科玉律」的原则,这些是该提案中代码已证明过度设计的部分;未来的观测者按其实际消费的需求重新引入最小的信号或查询,由该消费方塑造其形态。 +web seam RFC 有意指定了两者——事件作为最小的 HMR 可见性信号,status 方法作为工具的聚合诊断——且未来的 provider 状态面板是可以想象的。但同一 RFC 的其他设计选择使它们失去了消费方:按需派生的选择与基于 enablement 的注册使得没有消费方能需要这两者;已交付的工具展示了真实模式(执行并路由结构化错误);漂移的 README 语句表明承诺的消费方从未实现。按 AGENTS.md「RFC 是提案,不是金科玉律」的原则,这些是该提案中代码已证明过度设计的部分;未来的观测者按其实际消费的需求重新引入最小的信号或查询,由该消费方塑造其形态。 ## 验证 diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml index 4ca47feeee..a43d663e93 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-fold-stdio-ui-helper.md: 8d26af190a957b424960519f77cbe132291c74de -2026-07-04-fold-stdio-ui-helper.zh.md: 795edf082258a56d3c11afbbb8de8cfa0e74e74e +2026-07-04-fold-stdio-ui-helper.md: ab42f1d131f6c657edf078d953c646b7970e9782 +2026-07-04-fold-stdio-ui-helper.zh.md: 2ad6abf3d5fb5ae6cdd852f8b5ec7e061e62b8ed diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 8d26af190a..ab42f1d131 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -1,9 +1,9 @@ # RFC: Fold the stdio UI helper into the stdio app -English | [中文](2026-07-04-fold-stdio-ui-helper.zh.md) - Status: implemented +English | [中文](2026-07-04-fold-stdio-ui-helper.zh.md) + ## Problem The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-demo`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md index 795edf0822..2ad6abf3d5 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md @@ -1,9 +1,9 @@ -# RFC:将 stdio UI 辅助模块折入 stdio 应用 - -[English](2026-07-04-fold-stdio-ui-helper.md) | 中文 +# RFC: 将 stdio UI 辅助模块折入 stdio 应用 Status: implemented +[English](2026-07-04-fold-stdio-ui-helper.md) | 中文 + ## 问题 readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/dsh-ui-stdio`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载应用来使用 readline UI,从不自行组合该辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest(元数据清单)与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在,`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被明确标注为非产品表面的 support 包。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml index ef7e3c8092..1d12fb892b 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-producerless-vocabulary-variants.md: 271b97f6217a2694f36b7fe7339eab6176dba9e5 -2026-07-04-prune-producerless-vocabulary-variants.zh.md: 2fe8d41a011c37919bd01022d5be6d309b865bf7 +2026-07-04-prune-producerless-vocabulary-variants.md: f1b80e35b9004e40fb0ffdd08310310848912d09 +2026-07-04-prune-producerless-vocabulary-variants.zh.md: 9e7b55256ba5bda0474fd9056eea9a96beaf8096 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md index 271b97f621..f1b80e35b9 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -1,9 +1,9 @@ # RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) -English | [中文](2026-07-04-prune-producerless-vocabulary-variants.zh.md) - Status: implemented +English | [中文](2026-07-04-prune-producerless-vocabulary-variants.zh.md) + ## Problem The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violated that policy — each had no producer and no consumer, and two had not even a test: diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md index 2fe8d41a01..9e7b55256b 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md @@ -1,9 +1,9 @@ -# RFC:裁剪无生产者的词汇变体(块缓存提示、`agent` 消息来源、`continuation` 轮次触发器) - -[English](2026-07-04-prune-producerless-vocabulary-variants.md) | 中文 +# RFC: 裁剪无生产者的词汇变体(块缓存提示、`agent` 消息来源、`continuation` 轮次触发器) Status: implemented +[English](2026-07-04-prune-producerless-vocabulary-variants.md) | 中文 + ## 问题 可合并扩展的词汇映射表设计上通过声明合并来增长,代码库已在 `TurnEndReasonMap`(`packages/core/session/src/types.ts`)上明确了准入策略:像 `refusal` 这样的变体「在适配器或循环首次发出它之前,有意不纳入」。三个已声明的词汇项违反了该策略——每个都既无生产者也无消费方,其中两个甚至没有测试: diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml index 428dfb9379..a595aef18a 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-write-only-fs-surface.md: ac2cbcc282b848b26a3d5d327e0ab54612b5ac91 -2026-07-04-prune-write-only-fs-surface.zh.md: e854df76cae74033404aa9cc1986fdd118f19b10 +2026-07-04-prune-write-only-fs-surface.md: f41619ecde1bbf2a1d6d8f8d769409f624fc22c7 +2026-07-04-prune-write-only-fs-surface.zh.md: afcf1aad28db056997930162539a57bb08bbe815 diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md index ac2cbcc282..f41619ecde 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md @@ -1,9 +1,9 @@ # RFC: Prune write-only fields and a dead routing knob from the fs seam -English | [中文](2026-07-04-prune-write-only-fs-surface.zh.md) - Status: implemented +English | [中文](2026-07-04-prune-write-only-fs-surface.zh.md) + ## Problem The [fs seam split](2026-06-26-fsspec-style-fs-seam.md) moved read routing and policy out of the backend into `dsh-tool-fs` and `dsh-fs-policy`. Four pieces of surface kept the pre-split shape — populated on every call, read by nobody: diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md index e854df76ca..afcf1aad28 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md @@ -1,9 +1,9 @@ -# RFC:从 fs seam 中移除只写字段与一个无效的路由旋钮 - -[English](2026-07-04-prune-write-only-fs-surface.md) | 中文 +# RFC: 从 fs seam 中移除只写字段与一个无效的路由旋钮 Status: implemented +[English](2026-07-04-prune-write-only-fs-surface.md) | 中文 + ## 问题 [fs seam 拆分](2026-06-26-fsspec-style-fs-seam.md)将读取路由与策略从后端移至 `dsh-tool-fs` 和 `dsh-fs-policy`。有四处接口保留了拆分前的形态——每次调用都填充,却无人读取: diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml index be2b32be56..ca0bd62294 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-remove-agent-steering-mirror.md: 311f0f8ffd278adf71a35617b900d4d16037055a -2026-07-04-remove-agent-steering-mirror.zh.md: 24198afb0714863867f4d7e17ae19ea8af6a88bd +2026-07-04-remove-agent-steering-mirror.md: fbd13b3d43b0052bcdeffd7f94caa341e1f636c5 +2026-07-04-remove-agent-steering-mirror.zh.md: 185cc5601a7406e0d801afd877e9d97eaaa12a0c diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md index 311f0f8ffd..fbd13b3d43 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md @@ -1,9 +1,9 @@ # RFC: Remove the `agent/steering` mirror emit -English | [中文](2026-07-04-remove-agent-steering-mirror.zh.md) - Status: implemented +English | [中文](2026-07-04-remove-agent-steering-mirror.zh.md) + ## Problem `agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above. diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md index 24198afb07..185cc5601a 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md @@ -1,9 +1,9 @@ -# RFC:移除 `agent/steering` 镜像 emit - -[English](2026-07-04-remove-agent-steering-mirror.md) | 中文 +# RFC: 移除 `agent/steering` 镜像 emit Status: implemented +[English](2026-07-04-remove-agent-steering-mirror.md) | 中文 + ## 问题 `agent/steering` 是最后一个仍存在的、对持久会话事件的瞬态镜像。agent loop(智能体循环)的 steering(中途引导)drain 逻辑先追加持久事件 `steering/message { turn, content, source }`,紧接着下一行就 emit `agent/steering(agent, turn, content, source)`——同一个事实以 fire-and-forget 事件的形式重复发出(`packages/core/agent-loop/src/loop.ts`,`drainSteering`)。它在生产环境中没有任何监听者:唯一的订阅方是一个 agent loop 回归测试,断言 emit 携带了 `source`——而这同一个事实已经由上一行的持久事件记录。 @@ -22,7 +22,7 @@ steering 承载着真实的生产流量:hook bridge 的轮次续行决策通 ### 为什么不保留? -"它是控制信号,不是边界事件"——但分类体系的操作性区分是「镜像 vs. 纯瞬态」,而非「控制 vs. 边界」,而这个事件属于镜像。需要入队时通知的消费方有 `agent/queued`(带 steering flag);需要 drain 时通知的消费方,本质上是在请求 `steering/message` 被追加的那一刻,而 `session/event` 以相同 payload 加上持久性提供了这一通知。被否决的 [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) 捍卫的是 steering **能力**——`steer()`、持久事件、续行强制——本次移除对这些全部保持不变。 +"它是控制信号,不是边界事件"——但分类体系的操作性区分是「镜像 vs. 纯瞬态」,而非「控制 vs. 边界」,而这个事件属于镜像。需要入队时通知的消费方有 `agent/queued`(带 steering flag);需要 drain 时通知的消费方,本质上是在请求 `steering/message` 被追加的那一刻,而 `session/event` 以相同 payload 加上持久性提供了这一通知。被否决的 [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) 捍卫的是 steering *能力*——`steer()`、持久事件、续行强制——本次移除对这些全部保持不变。 ## 验证 diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml index 6a470484f3..788e1735b5 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-share-app-bin-boot-glue.md: afaa61fe909f3dbf900337518969410780020a88 -2026-07-04-share-app-bin-boot-glue.zh.md: 33fe2f27df9c8b172e4296ece0720813f9775f86 +2026-07-04-share-app-bin-boot-glue.md: 31666e74bb0bb0086de85e6a3afafbc2f73a6e52 +2026-07-04-share-app-bin-boot-glue.zh.md: d18f83be0f76774e39a1e54f1ea2db3c1c1b7688 diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md index afaa61fe90..31666e74bb 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -1,9 +1,9 @@ # RFC: Share the app bins' boot glue instead of maintaining twin copies -English | [中文](2026-07-04-share-app-bin-boot-glue.zh.md) - Status: implemented +English | [中文](2026-07-04-share-app-bin-boot-glue.zh.md) + ## Problem The stdio and ACP bins duplicated environment loading, fail-loud handling, entry validation, and boot logic, including subtle Loader failure behavior. Their copies had already drifted and lived in self-executing files excluded from unit coverage, making their helper exports unusable. diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md index 33fe2f27df..d18f83be0f 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md @@ -1,9 +1,9 @@ -# RFC:共享应用 bin 的启动胶水代码,而非维护两份副本 - -[English](2026-07-04-share-app-bin-boot-glue.md) | 中文 +# RFC: 共享应用 bin 的启动胶水代码,而非维护两份副本 Status: implemented +[English](2026-07-04-share-app-bin-boot-glue.md) | 中文 + ## 问题 stdio 和 ACP 两个 bin 各自重复了环境加载、fail-loud 处理、入口校验与启动逻辑,包括微妙的 Loader 失败行为。两份副本已经发生漂移,且位于自执行文件中、被排除在单元测试覆盖率之外,导致其导出的辅助函数无法被复用。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml index 95c2fdbf50..b2926299df 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-tighten-hook-protocol-contract.md: df438516b836315902378afe7f4fd09e512c0966 -2026-07-04-tighten-hook-protocol-contract.zh.md: 256da42993c8581e8bce861ecbfac22dbf5f0545 +2026-07-04-tighten-hook-protocol-contract.md: 92ed629edaa0364d88955458f39f6280322e79c7 +2026-07-04-tighten-hook-protocol-contract.zh.md: 9b5b19d7ad522fdf74eb330c259d8dee03bee604 diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md index df438516b8..92ed629eda 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -1,9 +1,9 @@ # RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics -English | [中文](2026-07-04-tighten-hook-protocol-contract.zh.md) - Status: implemented +English | [中文](2026-07-04-tighten-hook-protocol-contract.zh.md) + ## Problem Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test: diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md index 256da42993..9b5b19d7ad 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md @@ -1,9 +1,9 @@ -# RFC:收紧 hook-protocol 契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 - -[English](2026-07-04-tighten-hook-protocol-contract.md) | 中文 +# RFC: 收紧 hook-protocol 契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 Status: implemented +[English](2026-07-04-tighten-hook-protocol-contract.md) | 中文 + ## 问题 `dsh-hook-protocol`/bridge 契约中有四处遗漏了 [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) 所记录的纪律——该 RFC 因缺乏消费方而移除了 `agentType` 生命周期字段,以下四处未通过同样的检验: diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml index aa5df52a6a..f0afae419b 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-trim-acp-bridge-unreachable-surface.md: 6decb494dcbfd348777577002187007597a8c374 -2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 9d588e6f1132869ead60586b4df6844400307863 +2026-07-04-trim-acp-bridge-unreachable-surface.md: 05a62c92ec1553e6eb0b14adc86f8aa1b89827e5 +2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 851198eee0559013429ef4eb5491cd7f97217c46 diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md index 6decb494dc..05a62c92ec 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -1,9 +1,9 @@ # RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback -English | [中文](2026-07-04-trim-acp-bridge-unreachable-surface.zh.md) - Status: implemented +English | [中文](2026-07-04-trim-acp-bridge-unreachable-surface.zh.md) + ## Problem Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md index 9d588e6f11..851198eee0 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md @@ -1,9 +1,9 @@ -# RFC:裁剪不可达的 ACP 桥接层表面——品牌配置项与 kind 嗅探回退 - -[English](2026-07-04-trim-acp-bridge-unreachable-surface.md) | 中文 +# RFC: 裁剪不可达的 ACP 桥接层表面——品牌配置项与 kind 嗅探回退 Status: implemented +[English](2026-07-04-trim-acp-bridge-unreachable-surface.md) | 中文 + ## 问题 `dsh-acp` 有两处对外表面在任何已交付的配置中都不可达: diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml index 593cb8a50e..3644236c38 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-drop-unconsumed-skill-provider-events.md: 5ec9d201939b8f58334647353f599361bd2e58a0 -2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 15dbfedb07afa36b677074c403812d0f164c8bd5 +2026-07-12-drop-unconsumed-skill-provider-events.md: 90157c03e5df05c98b992ce1dbefea26f4865ce7 +2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 19fec4b827b89b4127b749a9c77715baf39dd00a diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md index 5ec9d20193..90157c03e5 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md @@ -1,9 +1,9 @@ # RFC: Drop unconsumed skill provider events -English | [中文](2026-07-12-drop-unconsumed-skill-provider-events.zh.md) - Status: implemented +English | [中文](2026-07-12-drop-unconsumed-skill-provider-events.zh.md) + ## Problem Two skill-registry notifications are produced but have no production listener. The generated producer/consumer matrix and exact event-name searches find only declarations, emit sites, tests, generated catalogs, and prose for `skill/provider-added` and `skill/provider-removed`. diff --git a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md index 15dbfedb07..19fec4b827 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md @@ -1,9 +1,9 @@ -# RFC:移除无消费方的 skill 提供方事件 - -[English](2026-07-12-drop-unconsumed-skill-provider-events.md) | 中文 +# RFC: 移除无消费方的 skill 提供方事件 Status: implemented +[English](2026-07-12-drop-unconsumed-skill-provider-events.md) | 中文 + ## 问题 skill(技能)注册表产出两个通知事件,但没有生产环境的监听方。生成的生产者/消费方矩阵以及对事件名的精确搜索表明,`skill/provider-added` 与 `skill/provider-removed` 仅出现在声明、emit 站点、测试、生成的 catalog 和行文中。 diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml index 7413576228..703539b152 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-prune-unused-web-seam-fields.md: b4773c2706cf6d18ea4bb96720cd6c932cdf8942 -2026-07-12-prune-unused-web-seam-fields.zh.md: 2c18fbcb440ce85798c8f36cdc5dc649149d8ba9 +2026-07-12-prune-unused-web-seam-fields.md: 9fd05da282c22d78dc98e232ef2c23cd6e9c4ea3 +2026-07-12-prune-unused-web-seam-fields.zh.md: 650b6b74c808c719bcea9783c60064936427ffee diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md index b4773c2706..9fd05da282 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md @@ -1,9 +1,9 @@ # RFC: Prune unused web seam fields -English | [中文](2026-07-12-prune-unused-web-seam-fields.zh.md) - Status: implemented +English | [中文](2026-07-12-prune-unused-web-seam-fields.zh.md) + ## Problem The web capability carries request/result/status values that every shipped implementation populates but no production consumer reads. `WebSearchResult.providerId` and `query` and `WebFetchResult.providerId` are result echoes; `tool-web` formats only content/sources/truncation or final URL/status/body/truncation, and no other runtime reads them. Search providers return `WebProviderStatus.reason`, but resolution checks only `available` and intentionally emits a generic unavailable diagnostic. diff --git a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md index 2c18fbcb44..650b6b74c8 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md @@ -1,9 +1,9 @@ -# RFC:裁剪 web seam 中未使用的字段 - -[English](2026-07-12-prune-unused-web-seam-fields.md) | 中文 +# RFC: 裁剪 web seam 中未使用的字段 Status: implemented +[English](2026-07-12-prune-unused-web-seam-fields.md) | 中文 + ## 问题 web 能力携带的 request/result/status 值,虽然每个已交付的实现都会填充,但没有任何生产环境的消费方读取它们。`WebSearchResult.providerId`、`query`与 `WebFetchResult.providerId` 是结果回显;`tool-web` 只格式化 content/sources/truncation 或最终 URL/status/body/truncation,没有其他运行时读取这些字段。搜索提供方返回 `WebProviderStatus.reason`,但可用性检查只看 `available`,并有意输出一条通用的不可用诊断信息。 diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml index cc2bf1f23f..22d74d7424 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-property-based-testing.md: 169989746ea5114b1f35e7ebe35a02e8aeb0f782 -2026-06-11-property-based-testing.zh.md: 4f2303010f44279c4edd0ec5a509b71f8aad606b +2026-06-11-property-based-testing.md: 153584d3a2b77c8f2d103db02646f18a9d424b57 +2026-06-11-property-based-testing.zh.md: e11d11f7db9eee97ab81bc678afab5d0fea36bf9 diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 169989746e..153584d3a2 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -1,9 +1,9 @@ # RFC: Property-based testing for protocol-shaped code -English | [中文](2026-06-11-property-based-testing.zh.md) - Status: implemented +English | [中文](2026-06-11-property-based-testing.zh.md) + > Merges the original proposal and the decision record for one topic. It found a real BlockAssembler duplicate-`block-end` bug on first run. ## Problem diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md index 4f2303010f..e11d11f7db 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.zh.md @@ -1,4 +1,4 @@ -# RFC:对协议形态代码进行基于属性的测试 +# RFC: 对协议形态代码进行基于属性的测试 Status: implemented @@ -14,7 +14,7 @@ Status: implemented 引入 `fast-check`(作为根 devDependency),在每个协议形态的包(package)中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。(原始提案还草拟了一个夜间 CI job,以 100 倍迭代运行;该部分未交付。属性测试套件仅在常规的 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。) -- **dsh-llm / BlockAssembler:** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start)。不变式:`blocks()` 计数 ≤ 已见到的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且仅产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无 `finish` 分片时默认为 `{kind:'stop'}`。 +- **dsh-llm / BlockAssembler:** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start)。不变式:`blocks()` 计数 ≤ 已见到的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且仅产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无此类分片时默认为 `{kind:'stop'}`。 - **dsh-session:** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致;seq 严格单调递增;非消息事件不影响推导出的历史;推导出的内容与日志解耦。 - **dsh-tools:** 任意 `SchemaSpec`。不变式:JSON Schema 的 `required` 等于每一层 `required:true` 的键集;转换是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`,而定向破坏(删除必填键、顶层非对象)被拒绝。这封堵了 validator 与 `InferArgs` 漂移的风险。 - **dsh-agent-loop:** 任意发送调度,对接一个永不耗尽的适配器,通过 `agent/status` settle 信号驱动(无挂钟 sleep)。不变式:无消息丢失;轮次编号严格递增;状态转换保持在合法状态机上。 diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 1dfb26a628..10573d8e31 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-19-acp-snapshot-tests.md: 0b93c99932a33bca9945dd88ce45f4a1e100ccfc -2026-06-19-acp-snapshot-tests.zh.md: 26c583ba47b8ec10ab3d0e2102a8b791549fda38 +2026-06-19-acp-snapshot-tests.md: c336b4864b73b8db29c0a8bb983d974348a9515a +2026-06-19-acp-snapshot-tests.zh.md: bc9488562b0698b172ccffff74815a893acf722d diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 0b93c99932..c336b4864b 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -1,9 +1,9 @@ # RFC: ACP snapshot tests — record-once / replay-deterministic -English | [中文](2026-06-19-acp-snapshot-tests.zh.md) - Status: implemented +English | [中文](2026-06-19-acp-snapshot-tests.zh.md) + ## Problem Unit tests do not exercise the complete ACP subprocess transcript, while real-API tests are nondeterministic and key-gated. Editor-facing `session/update` output can therefore regress despite green unit coverage, as the [default-export postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) demonstrated. diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 26c583ba47..bc9488562b 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -1,4 +1,4 @@ -# RFC:ACP 快照测试——一次录制 / 确定性回放 +# RFC: ACP 快照测试——一次录制 / 确定性回放 Status: implemented diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index 591f51750a..bb98e02fd7 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-19-real-api-e2e-ci.md: 3b5995a3e060ef9b4b1639b5c7fb17c819e73150 -2026-06-19-real-api-e2e-ci.zh.md: 78bab7c1e00125bfbe11b8f08eeeff3f2b7723b1 +2026-06-19-real-api-e2e-ci.md: cc3e14e2d411dfa4cc68132f649f4ed26ab1de1d +2026-06-19-real-api-e2e-ci.zh.md: 58a2a87541fd5b73b8272aa729f9dd09a429e4b4 diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index 3b5995a3e0..cc3e14e2d4 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -1,9 +1,9 @@ # RFC: Real-API e2e in CI against the external DeepSeek API -English | [中文](2026-06-19-real-api-e2e-ci.zh.md) - Status: implemented +English | [中文](2026-06-19-real-api-e2e-ci.zh.md) + ## Problem The harness leans hard on real-API tests by policy: [docs/testing.md](../../../testing.md) argues that a no-key suite proves the plumbing but not the product, and the [ACP inject postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) is the standing proof — 178 keyless tests stayed green while a real editor session crashed instantly. The real-API e2e suite (`pnpm run test:e2e`, the `*.e2e.ts` files) exists precisely to close that gap: it drives the agent against the live DeepSeek API — real model calls, real bash tools, multi-turn, resume, ACP-over-stdio. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 78bab7c1e0..58a2a87541 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -1,9 +1,9 @@ -# RFC:在 CI 中对外部 DeepSeek API 运行真实 API e2e 测试 - -[English](2026-06-19-real-api-e2e-ci.md) | 中文 +# RFC: 在 CI 中对外部 DeepSeek API 运行真实 API e2e 测试 Status: implemented +[English](2026-06-19-real-api-e2e-ci.md) | 中文 + ## 问题 按照策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../testing.md) 论证了无密钥套件只能验证管道连通性而非产品本身,[ACP inject 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)是现成的证据——178 个无密钥测试全绿,而真实编辑器会话一启动就崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)正是为弥合这一差距而存在的:它驱动 agent(智能体)对接实时 DeepSeek API——真实模型调用、真实 bash 工具、多轮次对话、恢复、ACP-over-stdio。 diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml index 6e63ef2e55..5f59fbacf1 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-remove-redundant-snapshot-log-goldens.md: badd32d4479ac6d44bb7be3cd262cba57b1b3a38 -2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: b791fbcc971eb1340f0d43ef6a60ebb29e4711f9 +2026-06-20-remove-redundant-snapshot-log-goldens.md: 18d0a4491eb10a3b4dc56d3d63285c219ba6a00a +2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: 5675c69862b6052ed3f3e4710461cc1478b9fa7d diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index badd32d447..18d0a4491e 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -1,9 +1,9 @@ # RFC: Use `session.jsonl` as the only snapshot session-log artifact -English | [中文](2026-06-20-remove-redundant-snapshot-log-goldens.zh.md) - Status: implemented +English | [中文](2026-06-20-remove-redundant-snapshot-log-goldens.zh.md) + ## Problem Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md index b791fbcc97..5675c69862 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md @@ -1,4 +1,4 @@ -# RFC:使用 `session.jsonl` 作为唯一的快照会话日志产物 +# RFC: 使用 `session.jsonl` 作为唯一的快照会话日志产物 Status: implemented diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index f3bddc661c..cfae43ecf0 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-fork-child-replay-seed-boundary.md: a0bf064508107a23147df1a6c824c53a3906c43e -2026-06-22-fork-child-replay-seed-boundary.zh.md: 3825cce806c036c7fa21641a2f0b7cc0533d84bf +2026-06-22-fork-child-replay-seed-boundary.md: 28ce76309da2dca7076dd11211229a0631d11db3 +2026-06-22-fork-child-replay-seed-boundary.zh.md: 92b60589b4cfe9668a1542405693cec8d29eceaf diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index a0bf064508..28ce76309d 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -1,9 +1,9 @@ # RFC: Persist the seed boundary so fork-child replay routes correctly -English | [中文](2026-06-22-fork-child-replay-seed-boundary.zh.md) - Status: implemented +English | [中文](2026-06-22-fork-child-replay-seed-boundary.zh.md) + ## Problem The [per-session snapshot replay RFC](2026-06-22-subagent-snapshot-replay.md) made the snapshot tier express a nested-agent shape: a parent plus one recorded log per in-process subagent, each replayed as its own script keyed by calling session. It noted (§ Scope, final bullet) that a fork snapshot was "a trivial future addition, not a gap in the keying." That was wrong about a fork child specifically — not the keying, but the *script derivation*. diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index 3825cce806..92b60589b4 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -1,9 +1,9 @@ -# RFC:持久化 seed 边界以确保 fork 子会话回放正确路由 - -[English](2026-06-22-fork-child-replay-seed-boundary.md) | 中文 +# RFC: 持久化 seed 边界以确保 fork 子会话回放正确路由 Status: implemented +[English](2026-06-22-fork-child-replay-seed-boundary.md) | 中文 + ## 问题 [逐会话快照回放 RFC](2026-06-22-subagent-snapshot-replay.md) 让快照层表达了嵌套 agent(智能体)的形状:一个父会话加上每个进程内 subagent 各一份已录制的日志,各自作为独立脚本回放、以调用方会话为键。该 RFC 指出(§ Scope 末尾条目)fork 快照是「一个平凡的后续补充,不是键控方案的缺口」。这对 fork 子会话而言是错的——问题不在键控,而在*脚本推导*。 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml index d262a105ce..e487e40249 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-fork-snapshot-scenarios.md: baca94d6a1071ec38ee20ca841fc3472a870b1a2 -2026-06-22-fork-snapshot-scenarios.zh.md: b6f3f6a6f318a343d5e32573d39f11f59b509ee3 +2026-06-22-fork-snapshot-scenarios.md: a5324cbfa13b79c0ea60b74b689f1b19db99a725 +2026-06-22-fork-snapshot-scenarios.zh.md: 543382db86eb50b5f278a99de74586a13bff9eb7 diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md index baca94d6a1..a5324cbfa1 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -1,9 +1,9 @@ # RFC: Record fork and mixed spawn+fork snapshot scenarios -English | [中文](2026-06-22-fork-snapshot-scenarios.zh.md) - Status: implemented +English | [中文](2026-06-22-fork-snapshot-scenarios.zh.md) + ## Problem The [seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) made fork-child replay route correctly: `dsh-llm-replay` derives a child's script from the events at or after its persisted `seedLength` boundary, so a fork child's inherited parent prefix is not replayed as the child's own model calls. But it shipped with **no recorded fork scenario** — the slice was exercised only by `llm-replay`'s unit tests (a synthetic child fixture) and a persistence round-trip test. The full-transcript snapshot tier, the one net that boots the real `acp-agent` and replays an end-to-end nested transcript, had only spawn children (`subagent-spawn`, `subagent-multi`). A fork-routing regression that left the unit tests green would still have escaped the tier built to catch transcript regressions. diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md index b6f3f6a6f3..543382db86 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md @@ -1,4 +1,4 @@ -# RFC:记录 fork 与混合 spawn+fork 快照场景 +# RFC: 记录 fork 与混合 spawn+fork 快照场景 Status: implemented @@ -8,7 +8,7 @@ Status: implemented [seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) 使 fork 子会话的回放路由正确运作:`dsh-llm-replay` 从子会话持久化的 `seedLength` 边界处或之后的事件推导出子会话的脚本,因此 fork 子会话继承的父会话前缀不会被当作子会话自身的模型调用来回放。但该 RFC 交付时**没有记录 fork 场景**——该切片仅由 `llm-replay` 的单元测试(一个合成的子会话 fixture(测试前置数据))和一个持久化往返测试覆盖。全 transcript(文本记录)快照层(即启动真实 `acp-agent` 并回放端到端嵌套 transcript 的那张网)只有 spawn 子会话(`subagent-spawn`、`subagent-multi`)。如果一个 fork 路由回归让单元测试保持绿色,它仍然会逃过专为捕获 transcript 回归而建的那一层。 -表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都在 `cordis.yml` / `cordis.snapshot.yml` 中以两个面向模型的工具接入(`subagent` → spawn、`subagent_fork` → fork),harness 会收集每个子会话的日志,回放按 `seedLength` 为键转发各子会话的 fixture。缺少的是一个**已记录的场景**来驱动 fork 子会话走完这条路径。 +表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都在 `cordis.yml` / `cordis.snapshot.yml` 中以两个面向模型的工具接入(`subagent` → spawn、`subagent_fork` → fork),harness 会收集每个子会话的日志,回放按 `seedLength` 为键转发各子会话的 fixture。缺少的是一个*已记录的场景*来驱动 fork 子会话走完这条路径。 ## 决策 @@ -19,12 +19,12 @@ Status: implemented ### 为什么需要一个已完成的第一轮次 -fork 后端用父会话的**已完成轮次的平衡前缀**([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来初始化子会话。如果父会话在第一轮次就 fork,则没有已完成的轮次可继承,seed 为空(等价于全新 spawn,`seedLength` 为 0),这**不会**覆盖切片逻辑。因此两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立一个 codeword,子会话稍后被要求回忆它),第二个 prompt 委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带产物;真正承载验证的产物是子会话 fixture 中记录的 `seedLength`,回放切片消费的正是它。 +fork 后端用父会话的**已完成轮次的平衡前缀**([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来初始化子会话。如果父会话在第一轮次就 fork,则没有已完成的轮次可继承,seed 为空(等价于全新 spawn,`seedLength` 为 0),这不会覆盖切片逻辑。因此两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立一个 codeword,子会话稍后被要求回忆它),第二个 prompt 委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带产物;真正承载验证的产物是子会话 fixture 中记录的 `seedLength`,回放切片消费的正是它。 ## 后果 - fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话记录的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 -- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种**不同** subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。 +- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。 - 进程外(ACP)subagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。 - 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在无密钥时自动跳过,与所有已录制场景一致。 diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index 8bea2c8a5e..c8d42599e1 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-subagent-snapshot-replay.md: fbb2e5b93cced118a24f5560f229e2dc341bf3b2 -2026-06-22-subagent-snapshot-replay.zh.md: 6514a0bcb5db3948f6d8f4693b17a74e4a9ad926 +2026-06-22-subagent-snapshot-replay.md: 89fc4e8d4d267fd4df373a7fd82b8c6e742be6ea +2026-06-22-subagent-snapshot-replay.zh.md: 7dc234ab9c6e3bb1facd78e98aad15005d158325 diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index fbb2e5b93c..89fc4e8d4d 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -1,9 +1,9 @@ # RFC: Per-session snapshot replay for nested agents -English | [中文](2026-06-22-subagent-snapshot-replay.zh.md) - Status: implemented +English | [中文](2026-06-22-subagent-snapshot-replay.zh.md) + ## Problem The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subprocess, replays a recorded session through [`dsh-llm-replay`](../../../../packages/support/llm-replay), and diffs the normalized stdout transcript + re-persisted session log against committed goldens. It is the only tier that exercises the full editor-facing transcript end to end. diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index 6514a0bcb5..7dc234ab9c 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -1,9 +1,9 @@ -# RFC:嵌套 agent 的逐会话快照回放 - -[English](2026-06-22-subagent-snapshot-replay.md) | 中文 +# RFC: 嵌套 agent 的逐会话快照回放 Status: implemented +[English](2026-06-22-subagent-snapshot-replay.md) | 中文 + ## 问题 快照测试层(`pnpm run test:snapshot`)启动真实的 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放录制的会话,并将归一化后的 stdout transcript(文本记录)与重新持久化的会话日志对已提交的金标文件做 diff。这是唯一一个端到端验证完整编辑器侧 transcript 的测试层。 @@ -29,7 +29,7 @@ Status: implemented 活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id,因此活跃会话无法通过 id 相等绑定到脚本。取而代之的是**首次调用顺序**绑定:第一个发起任何模型调用的活跃会话认领第一份有序脚本(即父会话:`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。此后每个会话独立推进自己的游标。 -这种方式按**谁在调用**键控,而非按全局调用顺序。因此即使 subagent 将来并发或在后台运行(全局游标会导致交错),它仍然正确。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径与旧行为逐字节一致。活跃会话数多于录制脚本数时会快速失败报错(出现了未录制的 subagent),绝不会静默错误路由。 +这种方式按谁在调用键控,而非按全局调用顺序。因此即使 subagent 将来并发或在后台运行(全局游标会导致交错),它仍然正确。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径与旧行为逐字节一致。活跃会话数多于录制脚本数时会快速失败报错(出现了未录制的 subagent),绝不会静默错误路由。 子 fixture(测试前置数据)按 `createdAt` 排序,在兄弟会话严格顺序执行时与调用顺序一致。id 平局打破仅使退化碰撞具有确定性。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。 @@ -54,5 +54,5 @@ Status: implemented - `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。 - `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外同样有用(遥测、请求路由)。 -- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本**派生**逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。 +- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。 - 进程外(ACP)subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml index a0daca4800..8c06af4e98 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-hook-snapshot-matrix.md: 8505e82fb681975c7506102a3eb858a29ccc11c8 -2026-07-04-hook-snapshot-matrix.zh.md: 9a9f085400e938bc15c171f652d65f7bcdfa518f +2026-07-04-hook-snapshot-matrix.md: b365992c01e081e5698e81a9ff9682e9b8166ce6 +2026-07-04-hook-snapshot-matrix.zh.md: bcb8e5ba55a14dd0c299dac161146a19f18201eb diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index 8505e82fb6..b365992c01 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -1,9 +1,9 @@ # RFC: Hook snapshot matrix — end-to-end goldens for both bridges -English | [中文](2026-07-04-hook-snapshot-matrix.zh.md) - Status: implemented +English | [中文](2026-07-04-hook-snapshot-matrix.zh.md) + ## Problem The hook bridges — [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) (7 Claude Code hook points) and [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex) (5 Codex points) — map external hook commands onto the harness interception seams. They carry deep unit and coverage-spec coverage (every decision arm, every payload dialect, driven against a mocked seam) plus one key-gated e2e (`hooks.e2e.ts`, a live `PreToolUse` block). But the full-transcript snapshot tier — the one net that boots the real `acp-agent` subprocess, replays a recorded session keyless, and diffs the normalized ACP stdout + re-persisted log against committed goldens — covered exactly ONE hook: a Claude `UserPromptSubmit` block (`hook-cc-promptsubmit-block`). diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md index 9a9f085400..bcb8e5ba55 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md @@ -1,4 +1,4 @@ -# RFC:Hook 快照矩阵——覆盖两种 bridge 的端到端 golden 测试 +# RFC: Hook 快照矩阵——覆盖两种 bridge 的端到端 golden 测试 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——将外部 hook 命令映射到 harness 的拦截 seam 上。它们拥有深度的单元测试和 coverage-spec 覆盖率(每个决策分支、每种 payload 方言,均对 mock 的 seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但完整 transcript(文本记录)快照层:那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将规范化的 ACP stdout 与重新持久化的日志与已提交 golden 做 diff 的网,只覆盖了**一个** hook:Claude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。 +hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——将外部 hook 命令映射到 harness 的拦截 seam 上。它们拥有深度的单元测试和 coverage-spec 覆盖率(每个决策分支、每种 payload 方言,均对 mock 的 seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但完整 transcript(文本记录)快照层:那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将规范化的 ACP stdout 与重新持久化的日志与已提交 golden 做 diff 的网,只覆盖了一个 hook:Claude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。 这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实 hook 进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个 hook 点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex hook 能端到端触发。 @@ -29,22 +29,22 @@ hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) - **手工编写、无模型轮次**(无密钥、无 sidecar——派生的回放脚本为空;比对的是携带 `hook/*` 事件的 `rejected` 轮次):`hook-cc-promptsubmit-block`、`hook-codex-promptsubmit-block`。 - **对真实 API 录制、录制期间 hook 活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`(allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`(deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`(block 并附带反馈)、`hook-{cc,codex}-posttool-context`(accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞性 Stop hook 通过 steering(中途引导)强制多走一步)。 -每个 hook 命令只输出**固定字面量字符串**(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop hook 会在每一步都 force-continue。 +每个 hook 命令只输出固定字面量字符串(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop hook 会在每一步都 force-continue。 ### 三个 hook 点被有意排除在快照之外 在构建矩阵过程中发现,记录于此是因为这些遗漏是决策而非疏忽: -- **`SessionStart` 与 `SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,**没有**轮次绑定。由此产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的 golden 甚至无法在自身回放中复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在 bridge 的单元覆盖率中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——它们就可以纳入快照。) -- **`SubagentStop`** 是纯观察性的:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript **不写入任何内容**,因此 golden 与无 hook 运行逐字节一致,永远无法被证明失败——一道永远不会触发的守卫。它留在单元覆盖率中(`bridge.spec.ts` 已断言了纯观察调用)。 +- **`SessionStart` 与 `SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有轮次绑定。由此产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的 golden 甚至无法在自身回放中复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在 bridge 的单元覆盖率中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——它们就可以纳入快照。) +- **`SubagentStop`** 是纯观察性的:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript 不写入任何内容,因此 golden 与无 hook 运行逐字节一致,永远无法被证明失败——一道永远不会触发的守卫。它留在单元覆盖率中(`bridge.spec.ts` 已断言了纯观察调用)。 -因此,该矩阵覆盖了所有具有**确定性、可观测** transcript 足迹的 hook 点,涵盖两种方言。 +因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的 hook 点,涵盖两种方言。 ## 后果 - 每个具有可观测 transcript 的 bridge seam 映射现在都在完整 transcript 层级、在真实应用中、对两种方言受到守护——包括此前完全没有端到端覆盖率的 Codex bridge。录制的 golden 捕获了模型对 deny/block/force-continue 轮次的真实反应,这是手工编写的 transcript 只能猜测的。 - block 场景无需密钥(无模型轮次);其余场景从录制的 fixture(测试前置数据)无密钥回放。`pnpm run test:snapshot:record` 从真实 API 重新生成录制的 fixture,无密钥时自动跳过,与所有录制场景一致。 -- prove-red 纪律成立:篡改 hook 配置的输出(例如修改 deny 原因)会使其场景在回放时变红——hook 进程在回放期间**真实运行**(只有模型被回放),因此 golden 守护的是实际的 hook→seam→loop 路径,而非它的 mock。 +- prove-red 纪律成立:篡改 hook 配置的输出(例如修改 deny 原因)会使其场景在回放时变红——hook 进程在回放期间真实运行(只有模型被回放),因此 golden 守护的是实际的 hook→seam→loop 路径,而非它的 mock。 - `acp-agent` 演示现在加载了一个通常会无操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。 <!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml index 4aa9340c22..733f3a2115 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-single-source-acp-replay-config.md: 922bdcced50f8e289449e05b51774f202228b0f8 -2026-07-04-single-source-acp-replay-config.zh.md: b347186f362fa5454f7bd5106c2e261cb8a00b61 +2026-07-04-single-source-acp-replay-config.md: 51cbd54d45408df1548c9cc2522b07b5ffaac110 +2026-07-04-single-source-acp-replay-config.zh.md: 2aec0e0e46007be243c0386fc0fca92065ce3c9e diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md index 922bdcced5..51cbd54d45 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -1,9 +1,9 @@ # RFC: Single-source the acp-agent replay config -English | [中文](2026-07-04-single-source-acp-replay-config.zh.md) - Status: implemented +English | [中文](2026-07-04-single-source-acp-replay-config.zh.md) + ## Problem `examples/acp-agent` shipped two hand-maintained configs: `cordis.yml` (the live tree) and a `cordis.snapshot.yml` that mirrored it entry-for-entry with only the llm backend swapped — stripped of comments, the entire difference was the eight-line `llm-deepseek` stanza versus the two-line `llm-replay` stanza. Every app-shape change had to be made twice, and nothing gated the symmetry: if the copies drifted, the snapshot tier would silently exercise a different app than the one that ships — the ["green units, broken product" class of gap](../../../postmortem/0001-acp-default-export-drops-inject.md) the snapshot tier exists to close, reintroduced one level up, with reviewer vigilance as the only defense. diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md index b347186f36..2aec0e0e46 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md @@ -1,9 +1,9 @@ -# RFC:将 acp-agent 回放配置改为单一来源 - -[English](2026-07-04-single-source-acp-replay-config.md) | 中文 +# RFC: 将 acp-agent 回放配置改为单一来源 Status: implemented +[English](2026-07-04-single-source-acp-replay-config.md) | 中文 + ## 问题 `examples/acp-agent` 曾维护两份手写配置:`cordis.yml`(正式运行树)和 `cordis.snapshot.yml`(逐条镜像前者,仅替换 LLM(大语言模型)后端)。去掉注释后,全部差异只是八行的 `llm-deepseek` 段落换成两行的 `llm-replay` 段落。每次应用结构变更都要改两遍,且没有门禁保障对称性:一旦两份副本漂移,快照层就会悄悄测试一个与实际交付不同的应用——正是快照层本要消除的["单元测试全绿、产品却坏了"这类缺口](../../../postmortem/0001-acp-default-export-drops-inject.md),在上一层被重新引入,唯一的防线是评审者的警觉。 @@ -23,5 +23,5 @@ overlay 依赖一个 vendor 插件的事实,这是有意为之:include 在 ## 后果 - 向 `cordis.yml` 添加插件即自动进入回放树,无需第二次编辑;漂移这一类问题从结构上消失,而非靠门禁拦截。 -- overlay 依赖条目携带稳定的 `id:`。禁用补丁上的 `name` 断言防止误定位(id 被复用时补丁跳过而非禁用错误的插件)。如果 id 被**重命名**,补丁退化为跳过,其警告需要一个回放应用有意不具备的 logger——可观测结果是一条无效的无密钥 `llm-deepseek` 条目与 `llm-replay` 并存,回放输出仍然正确(`llm-replay` 拥有流的短路权);这属于配置腐烂,留给评审发现,不会产生错误的快照。顶层插入一个 id 与既有条目冲突的新条目时,loader 的 id map 以后者为准;当前配置无冲突,新增补丁行才是引入冲突的场所。 +- overlay 依赖条目携带稳定的 `id:`。禁用补丁上的 `name` 断言防止误定位(id 被复用时补丁跳过而非禁用错误的插件)。如果 id 被重命名,补丁退化为跳过,其警告需要一个回放应用有意不具备的 logger——可观测结果是一条无效的无密钥 `llm-deepseek` 条目与 `llm-replay` 并存,回放输出仍然正确(`llm-replay` 拥有流的短路权);这属于配置腐烂,留给评审发现,不会产生错误的快照。顶层插入一个 id 与既有条目冲突的新条目时,loader 的 id map 以后者为准;当前配置无冲突,新增补丁行才是引入冲突的场所。 - 如果未来回放树需要第二处差异(另一个后端被替换),只需多加一行补丁,而非再 fork 一份文件。 diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml index 3b2d92e28c..dfb09d507a 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-pin-request-header-content-in-one-scenario.md: 5ccaa23a268114c5ba37ec153f4960b47df13bfd -2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 909968430dc3e648e09eeeedd47436c35b90b870 +2026-07-06-pin-request-header-content-in-one-scenario.md: 0166b459fbb8d883f07fb195bdd5e025d70349de +2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 1ca7df68fc743b919c6769ae8fa40ea16eb3d88a diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md index 5ccaa23a26..0166b459fb 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md @@ -1,9 +1,9 @@ # RFC: Pin request-header content in one snapshot scenario -English | [中文](2026-07-06-pin-request-header-content-in-one-scenario.zh.md) - Status: implemented +English | [中文](2026-07-06-pin-request-header-content-in-one-scenario.zh.md) + ## Problem An ACP snapshot suite needs to prove the exact composed system prompt and tool-schema list sent in each `request/header`, but duplicating that content inside every `session.jsonl` makes a prompt or schema edit rewrite dozens of giant one-line JSON records. Keeping one raw header avoids the duplication but still makes prompt review poor: prose is JSON-escaped onto one line and mixed with thousands of characters of tool schemas. diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md index 909968430d..1ca7df68fc 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md @@ -1,4 +1,4 @@ -# RFC:在单个快照场景中固定请求头内容 +# RFC: 在单个快照场景中固定请求头内容 Status: implemented diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml index c582f34c23..e8c8a129b8 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-shared-acp-snapshot-package.md: 81714191a704af1a9ed029fb8004deac88e39427 -2026-07-08-shared-acp-snapshot-package.zh.md: f80c8e49e80e9bf287c84c0ff4fb00377fad5175 +2026-07-08-shared-acp-snapshot-package.md: c378222804251761a1b04f59c35799a97a1525f1 +2026-07-08-shared-acp-snapshot-package.zh.md: cda7a578d4643800736ff159a6427d3a0e3e0fae diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index 81714191a7..c378222804 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -1,9 +1,9 @@ # RFC: Extract the ACP snapshot suite into a support package -English | [中文](2026-07-08-shared-acp-snapshot-package.zh.md) - Status: implemented +English | [中文](2026-07-08-shared-acp-snapshot-package.zh.md) + ## Problem The ACP snapshot tier ([snapshot RFC](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure golden normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout-golden and log compares, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests). diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md index f80c8e49e8..cda7a578d4 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -1,9 +1,9 @@ -# RFC:将 ACP 快照套件提取为支持包 - -[English](2026-07-08-shared-acp-snapshot-package.md) | 中文 +# RFC: 将 ACP 快照套件提取为支持包 Status: implemented +[English](2026-07-08-shared-acp-snapshot-package.md) | 中文 + ## 问题 ACP 快照层([快照 RFC](2026-06-19-acp-snapshot-tests.md))由位于某个示例测试目录中的三个模块构成:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,收集持久化日志)、`snapshot-normalize.ts`(纯粹的 golden 规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体加 fixture(测试前置数据)守卫(record/replay 模式、stdout-golden 与日志比对、pinned-header 一致性守卫、orphan/required-file/single-pin 元测试)。 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index 59f21c938e..b355adb77d 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-16-typed-event-schemas.md: 8d14c3b2d90d8dcf295e122e95267c2c0d7b2a17 -2026-06-16-typed-event-schemas.zh.md: 34f46a87058b409ecdab38d851654db49d87e201 +2026-06-16-typed-event-schemas.md: 93e470218e810c9c9370dd1c7cae5420c93fa7bf +2026-06-16-typed-event-schemas.zh.md: bca4265527507750abe5b8c114f14508cee91cb9 diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index 8d14c3b2d9..93e470218e 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -1,9 +1,9 @@ # RFC: Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern) -English | [中文](2026-06-16-typed-event-schemas.zh.md) - Status: proposed +English | [中文](2026-06-16-typed-event-schemas.zh.md) + ## Problem The harness models its core vocabulary — content blocks, message sources, finish reasons, turn triggers, turn-end reasons, and session events — as **merge-extensible maps**: a TypeScript `interface` (e.g. `SessionEventMap`, `ContentBlockMap`) that plugins augment via declaration merging, with the public union derived as `Map[keyof Map]`. This is the repo's universal extension pattern, documented in [docs/architecture.md](../../../architecture.md) ("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`") and relied on by the `defineTool` `InferArgs` DSL and the `assertNever` exhaustiveness convention. diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index 34f46a8705..bca4265527 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -1,16 +1,16 @@ -# RFC:事件词汇的运行时 schema(Zod 与 merge-extensible-map 模式之辩) - -[English](2026-06-16-typed-event-schemas.md) | 中文 +# RFC: 事件词汇的运行时 schema(Zod 与 merge-extensible-map 模式之辩) Status: proposed +[English](2026-06-16-typed-event-schemas.md) | 中文 + ## 问题 harness 将其核心词汇——内容块、消息来源、结束原因、轮次触发器、轮次结束原因与会话事件——建模为 **merge-extensible map**:一个 TypeScript `interface`(如 `SessionEventMap`、`ContentBlockMap`),插件通过声明合并对其扩展,公开联合类型则以 `Map[keyof Map]` 派生。这是本仓库的通用扩展模式,记录在 [docs/architecture.md](../../../architecture.md) 中("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`"),`defineTool` 的 `InferArgs` DSL 和 `assertNever` 穷举约定都依赖于它。 该模式**仅存在于编译期**。类型在运行时消失:没有 schema 对象可供校验传入值、解析不可信输入或在运行时枚举变体。[会话持久化契约](../../implemented/architecture/2026-06-14-session-persistence.md)暴露了两个后果: -1. **持久化将 `event.data` 视为不透明 JSON。** JSONL/SQLite 后端对每个事件逐字 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性检查:拒绝 BigInt、函数、循环引用、非有限数等),而**非**结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在后续消费方的 `switch` 中才可能被捕获。 +1. **持久化将 `event.data` 视为不透明 JSON。** JSONL/SQLite 后端对每个事件逐字 `JSON.stringify`/`JSON.parse`;唯一的运行时守卫是 `isJsonValue`(往返可序列化性检查:拒绝 BigInt、函数、循环引用、非有限数等),而非结构校验。一个损坏但仍为合法 JSON 的事件数据(字段类型错误、字段缺失)会静默往返,只有在后续消费方的 `switch` 中才可能被捕获。 2. **插件新增变体没有运行时契约。** 一个通过声明合并添加新 `SessionEventMap` 键的插件,在自身代码中获得了编译期类型,但没有任何机制校验它产出的值是否符合它所声明的形状——无论是在生产者处、持久化边界处还是重新加载时。 由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化和插件边界拥有运行时 schema 而非被擦除的类型。 diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 290a729be7..b98f0fa227 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-generic-long-running-tool-runtime.md: ea773a651b5aeec87179aac2ed419f176486977f -2026-06-20-generic-long-running-tool-runtime.zh.md: 25c1b282b19bb7da08b552485e348c23b11dcecf +2026-06-20-generic-long-running-tool-runtime.md: 9b83a4443d6d654cda75c72fba3369a16be078e2 +2026-06-20-generic-long-running-tool-runtime.zh.md: c1684be4fd5c1c3c9d913e063f4ba66bb6064459 diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md index ea773a651b..9b83a4443d 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -1,9 +1,9 @@ # RFC: Extract a generic long-running tool runtime -English | [中文](2026-06-20-generic-long-running-tool-runtime.zh.md) - Status: proposed +English | [中文](2026-06-20-generic-long-running-tool-runtime.zh.md) + ## Problem The bash capability seam supports both foreground commands and long-running background tasks. Background support is large: the abstract executor exposes `start`, `get`, `ownerOf`, `list`, `readOutput`, `kill`, and `onTaskDone`; the local executor tracks tasks, incremental reads, owner tokens, process cleanup, and completion listeners; the model sees three tools (`bash`, `bash_output`, `bash_kill`); the tool plugin injects completion notices back into the owning agent's session. The local executor fences task access behind owner tokens because predictable global task ids are a cross-session read/kill hazard. diff --git a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 25c1b282b1..c1684be4fd 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -1,4 +1,4 @@ -# RFC:提取通用的长时间运行工具运行时 +# RFC: 提取通用的长时间运行工具运行时 Status: proposed diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml index ccab930c8f..3412d91229 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-pre-tool-input-rewrite.md: add84bfc76434eb25870f09e71860279663d291e -2026-06-30-pre-tool-input-rewrite.zh.md: 13d66208be07992fd414f2984c493557ad1e87a3 +2026-06-30-pre-tool-input-rewrite.md: 85ece78f3bf188b3b702b1af539256747c1cfab2 +2026-06-30-pre-tool-input-rewrite.zh.md: 6a5c2b52627d21476b96448dc120155eab7f2223 diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md index add84bfc76..85ece78f3b 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md @@ -1,9 +1,9 @@ # RFC: Pre-tool input rewrite — a consistent design -English | [中文](2026-06-30-pre-tool-input-rewrite.zh.md) - Status: proposed +English | [中文](2026-06-30-pre-tool-input-rewrite.zh.md) + ## Problem The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) defines `tools/pre-execute` as an allow/deny/ask gate over an execution whose identity is already protected and whose arguments are deeply frozen. Claude Code's `PreToolUse` hook also offers `updatedInput`, so a faithful bridge needs an explicit rewrite mechanism. A rewrite cannot be a mutation escape hatch on the existing execution object: it must keep the durable history, audit record, presentation, and executed value consistent. diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md index 13d66208be..6a5c2b5262 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md @@ -1,4 +1,4 @@ -# RFC:工具执行前输入重写——一致性设计 +# RFC: 工具执行前输入重写——一致性设计 Status: proposed @@ -10,7 +10,7 @@ Status: proposed ## 问题本质:执行前参数的三个读取方 -在 agent loop(智能体循环)中,工具调用的参数在工具执行**之前**就已提交到日志并被实时消费方读取: +在 agent loop(智能体循环)中,工具调用的参数在工具执行之前就已提交到日志并被实时消费方读取: 1. **`assistant/message`** 在工具分发之前追加——它是 `deriveMessages()` 回放时的模型历史来源,因此携带模型自身输出的工具调用参数。 2. **`tool/call`** 是持久化的审计记录,在 `ctx.tools.execute()` 之前追加。 @@ -22,7 +22,7 @@ Status: proposed 重写是一个「身份标识创建前的一致性事务」。当钩子提供 `updatedInput` 时,有效值必须在注册表构造其不可变的 `ToolExecution` 之前确定,并且必须原子地反映到全部三个读取方: -- `tool/call` 审计事件记录**重写后**的参数(原始参数保留在一个伴随字段中,作为审计线索——钩子修改了调用,原始参数与生效参数都是值得保留的事实)。 +- `tool/call` 审计事件记录重写后的参数(原始参数保留在一个伴随字段中,作为审计线索——钩子修改了调用,原始参数与生效参数都是值得保留的事实)。 - 派生历史中的 `assistant/message` 必须与实际执行一致。待评估的选项:就地重写 assistant 消息中的工具调用块(改变模型「看到自己说了什么」),或记录一条单独的修正让下一次请求携带。Claude Code 的模型是让模型看到重写已生效。 - 展示层(`presentCall`/`presentResult`)读取重写后的参数,使 UI 显示实际运行的内容。 diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index ab362ca036..e3ec2ad677 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-claude-code-and-codex-subagent-backends.md: 1ebf01dd8df0980f6c464be8b27033bdfab942f3 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: dd26a49962ee46a8ce0965557ff3dbd5805fdcfe +2026-07-07-claude-code-and-codex-subagent-backends.md: 5585ea30a5ba1b4200f096069a28ccf1c3cef727 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 2a6dd2cdea34cb777df5455f0bc12dc7073ff2a2 diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 1ebf01dd8d..5585ea30a5 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -1,9 +1,9 @@ # RFC: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents) -English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md) - Status: proposed +English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md) + ## Problem Add isolated subagent providers for Claude Code and Codex. The existing [named-provider seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) and [ACP backend](../../implemented/feature/2026-06-22-acp-subagent-backend.md) establish the process-boundary shape. A harness turn should be able to delegate a self-contained task to either product and receive its final answer without exposing parent secrets or inheriting host configuration from `~/.claude` or `~/.codex`. diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index dd26a49962..2a6dd2cdea 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -1,9 +1,9 @@ -# RFC:Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派) - -[English](2026-07-07-claude-code-and-codex-subagent-backends.md) | 中文 +# RFC: Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派) Status: proposed +[English](2026-07-07-claude-code-and-codex-subagent-backends.md) | 中文 + ## 问题 为 Claude Code 和 Codex 添加隔离的 subagent 提供方。既有的[命名提供方 seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [ACP 后端](../../implemented/feature/2026-06-22-acp-subagent-backend.md)已确立了进程边界的形状。harness 的一个轮次应能将一个自包含任务委派给上述任一产品,并接收其最终答案,同时不暴露父进程的密钥,也不继承来自 `~/.claude` 或 `~/.codex` 的宿主配置。 @@ -12,7 +12,7 @@ Status: proposed 两个兄弟提供方包(ACP 后端的结构变体),加一次提取: -- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI 作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个**产品**,而非 Anthropic 模型适配器——"claude" 保留给未来的 `dsh-llm` 适配器。 +- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI 作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——"claude" 保留给未来的 `dsh-llm` 适配器。 - `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 - `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`SENSITIVE_ENV_PATTERN`/`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose 阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 @@ -22,13 +22,13 @@ Status: proposed 两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 -**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会**替换**子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 RFC 范围内。 +**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 RFC 范围内。 **codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 - 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的 turn;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 - 审批是服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 -- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端**必须**预检认证状态,并在失败时大声结算为 `error`,而非等待 turn。 +- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时大声结算为 `error`,而非等待 turn。 - 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。 ## 隔离与凭证 @@ -43,7 +43,7 @@ Status: proposed Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不是成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` 为 `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。 -活性姿态,明确声明:teardown 时序是配置项,turn 时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但**刻意不设** turn 时长或启动超时——与 ACP 一致:turn 期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent turn 合理地可达数分钟,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。 +活性姿态,明确声明:teardown 时序是配置项,turn 时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意不设 turn 时长或启动超时——与 ACP 一致:turn 期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent turn 合理地可达数分钟,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。 ## 测试 @@ -61,7 +61,7 @@ dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号 ### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)? -Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在**执行引擎**之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 RFC,而非针对后端。 +Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 RFC,而非针对后端。 ### 为什么不用登录态凭证和用户自身的配置? diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml index a86bcca889..b7300fc7f3 100644 --- a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-interactive-side-sessions.md: 250a906d9ec339399a0e0e29e70b2b8dc189fa72 -2026-07-08-interactive-side-sessions.zh.md: d86a2b69232bc8ccad78555911b44cf727780e0c +2026-07-08-interactive-side-sessions.md: ac29f80b31492ce79512cc4d08e33480e0ac6258 +2026-07-08-interactive-side-sessions.zh.md: 5d0ef101dc23febefec881b12fcbb5ba4dcf8be9 diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md index 250a906d9e..ac29f80b31 100644 --- a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md @@ -1,9 +1,9 @@ # RFC: Interactive side sessions and merge-back -English | [中文](2026-07-08-interactive-side-sessions.zh.md) - Status: proposed +English | [中文](2026-07-08-interactive-side-sessions.zh.md) + ## Problem A user may want to explore a question from a live session without changing its main context. Existing primitives do not expose that product shape: [session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) creates an unattached session, while [fork subagents](../../implemented/feature/2026-06-21-subagent-capability-seam.md) are model-driven tasks whose transcript collapses into one tool result. Neither gives the user a separate conversation, and neither records a conclusion back into the parent with provenance. diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md index d86a2b6923..5d0ef101dc 100644 --- a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -1,9 +1,9 @@ -# RFC:交互式侧会话与合并回写 - -[English](2026-07-08-interactive-side-sessions.md) | 中文 +# RFC: 交互式侧会话与合并回写 Status: proposed +[English](2026-07-08-interactive-side-sessions.md) | 中文 + ## 问题 用户可能希望在不改变当前会话主上下文的前提下,探索一个来自活跃会话的问题。现有原语无法提供这种产品形态:[session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) 创建的是一个无关联的会话,而 [fork subagent](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 是模型驱动的任务,其 transcript(文本记录)会折叠为一条工具结果。两者都不能给用户一个独立的对话,也都不能将结论带着出处信息记录回父会话。 diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml index ab712ad131..5a09704cff 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-sqlite-session-query-provider.md: 8b67baf420433feca9d5cd09d58852bb9b1545a9 -2026-07-10-sqlite-session-query-provider.zh.md: ad6b44363ab54b66b597941adb13938f27971bd5 +2026-07-10-sqlite-session-query-provider.md: d1901ab0e37e8f92af322facac0cb48988d1ffe7 +2026-07-10-sqlite-session-query-provider.zh.md: 7a86d7294183eec57c3495a182f1403f84c27363 diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md index 8b67baf420..d1901ab0e3 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -1,9 +1,9 @@ # RFC: SQLite FTS5 session search -English | [中文](2026-07-10-sqlite-session-query-provider.zh.md) - Status: proposed +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. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md index ad6b44363a..7a86d72941 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md @@ -1,9 +1,9 @@ -# RFC:SQLite FTS5 会话搜索 - -[English](2026-07-10-sqlite-session-query-provider.md) | 中文 +# RFC: SQLite FTS5 会话搜索 Status: proposed +[English](2026-07-10-sqlite-session-query-provider.md) | 中文 + ## 问题 精确读取的 `ctx.sessionQuery` 服务有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不是每次查询都扫描全部事件;当前的活跃会话则需要一个比上一次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤、分页、取消以及重建行为。 diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml index a90fafdf8b..802373dd50 100644 --- a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-13-stream-workflow-progress-through-tool-calls.md: 525f2793052a80d82de29d2d370cfd747d002af6 -2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: 8dcb4aea2de50cdd278c702c9f6c85ab66e6be34 +2026-07-13-stream-workflow-progress-through-tool-calls.md: c4fe68974bf774306038e3bd2ba3e29e06de3492 +2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: b6a3ccbc89bebaae92641a10aea9a9a05b38a293 diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md index 525f279305..c4fe68974b 100644 --- a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md @@ -1,9 +1,9 @@ # RFC: Stream workflow progress through tool calls -English | [中文](2026-07-13-stream-workflow-progress-through-tool-calls.zh.md) - Status: proposed +English | [中文](2026-07-13-stream-workflow-progress-through-tool-calls.zh.md) + ## Problem The workflow engine intentionally emits balanced `workflow/*` observation events for run, phase, narration, and child-agent progress, but no production consumer presents them. Editors therefore show one pending workflow tool card until the final result even while the engine already reports which phase is active, what the script logged, and which children started or settled. The [dynamic-workflows decision](../../implemented/feature/2026-07-05-dynamic-workflows.md) explicitly reserves ACP progress UI for this event stream. diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md index 8dcb4aea2d..b6a3ccbc89 100644 --- a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md @@ -1,9 +1,9 @@ -# RFC:通过工具调用流式传输工作流进度 - -[English](2026-07-13-stream-workflow-progress-through-tool-calls.md) | 中文 +# RFC: 通过工具调用流式传输工作流进度 Status: proposed +[English](2026-07-13-stream-workflow-progress-through-tool-calls.md) | 中文 + ## 问题 工作流引擎有意为 run、phase、narration 和子 agent(智能体)进度发出成对的 `workflow/*` observation 事件,但目前没有生产消费方呈现这些事件。因此,编辑器在最终结果返回之前只显示一张 pending 状态的工作流工具卡片,尽管引擎已经报告了当前活跃的 phase、脚本日志内容以及哪些子 agent 已启动或已结束。[dynamic-workflows 决策](../../implemented/feature/2026-07-05-dynamic-workflows.md)明确将 ACP(Agent Client Protocol)进度 UI 保留给这一事件流。 diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml index fc5effd89a..f7f98a72bb 100644 --- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-api-extractor-reports.md: 0f3f736ba662fd6366eb8d7f26887fb319b2b563 -2026-06-11-api-extractor-reports.zh.md: cf0eb3f9edbcfb2ae862f075af0628e716693a86 +2026-06-11-api-extractor-reports.md: 26562267d188ab2427075c6fccf0ee4b24d63d99 +2026-06-11-api-extractor-reports.zh.md: 33e80abc6e9689cf90f3c851039144a53418137e diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md index 0f3f736ba6..26562267d1 100644 --- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.md @@ -1,9 +1,9 @@ # RFC: API extractor reports -English | [中文](2026-06-11-api-extractor-reports.zh.md) - Status: proposed +English | [中文](2026-06-11-api-extractor-reports.zh.md) + > Split out from the original "Doc-sync and API reports" RFC (2026-06-11). Parts 1-2 (doc-block typechecking, event-taxonomy verification) shipped — see [doc-sync enforcement](../../implemented/process/2026-06-11-doc-sync-enforcement.md). This is the deferred part 3, kept as a standalone proposal. ## Problem diff --git a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md index cf0eb3f9ed..33e80abc6e 100644 --- a/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md +++ b/docs/rfc/proposed/process/2026-06-11-api-extractor-reports.zh.md @@ -1,9 +1,9 @@ -# RFC:API extractor 报告 - -[English](2026-06-11-api-extractor-reports.md) | 中文 +# RFC: API extractor 报告 Status: proposed +[English](2026-06-11-api-extractor-reports.md) | 中文 + > 从最初的「Doc-sync 与 API 报告」RFC(2026-06-11)中拆出。第 1–2 部分(文档块类型检查、事件分类体系校验)已交付,见 [doc-sync 强制](../../implemented/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 ## 问题 diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml index 094c2c349b..b358826e19 100644 --- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-architectural-conformance.md: 40858d049af2df1928e27280238d0b198a5202f7 -2026-06-11-architectural-conformance.zh.md: b68355dc1c04a4f807efdb95f813159cb7f9f178 +2026-06-11-architectural-conformance.md: aad11b9e4bcbd31465cb0c4a507654971e24e843 +2026-06-11-architectural-conformance.zh.md: 59684bd01a133755a3d7efd90832f6f268037920 diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md index 40858d049a..aad11b9e4b 100644 --- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.md @@ -1,9 +1,9 @@ # RFC: Architectural conformance — dependency rules and the adapter kit -English | [中文](2026-06-11-architectural-conformance.zh.md) - Status: proposed +English | [中文](2026-06-11-architectural-conformance.zh.md) + ## Problem Two architectural guarantees currently live only in prose: (1) nothing depends on the concrete loop package ([the microkernel promise](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)), and (2) every LlmAdapter speaks the chunk protocol correctly. Both should be mechanical ([the quality-gates principle](../../implemented/process/2026-06-11-quality-gates.md)). diff --git a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md index b68355dc1c..59684bd01a 100644 --- a/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md +++ b/docs/rfc/proposed/process/2026-06-11-architectural-conformance.zh.md @@ -1,9 +1,9 @@ -# RFC:架构一致性——依赖规则与适配器套件 - -[English](2026-06-11-architectural-conformance.md) | 中文 +# RFC: 架构一致性——依赖规则与适配器套件 Status: proposed +[English](2026-06-11-architectural-conformance.md) | 中文 + ## 问题 目前有两项架构保证仅存在于行文中:(1)没有任何东西依赖具体的 loop 包([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确地遵循 chunk 协议。二者都应当是机械化的([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml index 62e4b4aa6f..7e89d07146 100644 --- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-supply-chain-and-vendor-drift.md: 306e185e9175e3e7af24455cf95167f54b3d1c17 -2026-06-11-supply-chain-and-vendor-drift.zh.md: a840e766c49182d7a9ca648acbbab1a2762688f5 +2026-06-11-supply-chain-and-vendor-drift.md: 97f5a3f999936faf81a67fe69c91f773400cb447 +2026-06-11-supply-chain-and-vendor-drift.zh.md: 0a8441c104ca4779a79b36361ea5d84f5cd09aca diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md index 306e185e91..97f5a3f999 100644 --- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.md @@ -1,9 +1,9 @@ # RFC: Supply chain checks and vendor drift verification -English | [中文](2026-06-11-supply-chain-and-vendor-drift.zh.md) - Status: proposed +English | [中文](2026-06-11-supply-chain-and-vendor-drift.zh.md) + ## Problem The vendor manifest ([the vendoring decision](../../implemented/process/2026-06-11-vendor-cordis-as-source.md)) is enforced at commit time in the *forward* direction (vendored change ⇒ manifest update) but nothing verifies the manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus exactly the logged modifications. And the handful of true npm dependencies have no advisory monitoring or update cadence. diff --git a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md index a840e766c4..0a8441c104 100644 --- a/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md +++ b/docs/rfc/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md @@ -1,12 +1,12 @@ -# RFC:供应链检查与 vendor 漂移验证 - -[English](2026-06-11-supply-chain-and-vendor-drift.md) | 中文 +# RFC: 供应链检查与 vendor 漂移验证 Status: proposed +[English](2026-06-11-supply-chain-and-vendor-drift.md) | 中文 + ## 问题 -vendor manifest(元数据清单)(见[引入 vendor 的决策](../../implemented/process/2026-06-11-vendor-cordis-as-source.md))在提交时仅在**正向**强制执行(vendor 变更 ⇒ manifest 更新),但没有任何机制验证 manifest 的**声明**:即 vendor/ 确实等于上游指定 SHA 的内容加上所记录的修改。此外,少量真正的 npm 依赖也没有安全公告监控或更新节奏。 +vendor manifest(元数据清单)(见[引入 vendor 的决策](../../implemented/process/2026-06-11-vendor-cordis-as-source.md))在提交时仅在*正向*强制执行(vendor 变更 ⇒ manifest 更新),但没有任何机制验证 manifest 的*声明*:即 vendor/ 确实等于上游指定 SHA 的内容加上所记录的修改。此外,少量真正的 npm 依赖也没有安全公告监控或更新节奏。 ## 提案 diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml index ddaafef6da..678dcb4abe 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-discover-package-inventory.md: 22b3e9acbe4dad8ef829d0dd30415c516031d66b -2026-06-20-discover-package-inventory.zh.md: 4eeaed9ed6b390608095281775883f8e7a52e954 +2026-06-20-discover-package-inventory.md: 6729b8ea4386b1595139a2827a95cf3b072e8c94 +2026-06-20-discover-package-inventory.zh.md: 71a6fdf97255932dcff11b574ef7c67ef5a39313 diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 22b3e9acbe..6729b8ea43 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -1,9 +1,9 @@ # RFC: Discover package inventories instead of maintaining static lists -English | [中文](2026-06-20-discover-package-inventory.zh.md) - Status: proposed +English | [中文](2026-06-20-discover-package-inventory.zh.md) + ## Problem Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, Knip overrides, and snapshot scenario metadata. Most restate package layout, manifest data, aggregate command contents, or fixture files. Each new package or scenario therefore creates avoidable synchronization points. diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md index 4eeaed9ed6..71a6fdf972 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.zh.md @@ -1,4 +1,4 @@ -# RFC:通过发现机制获取包清单,而非维护静态列表 +# RFC: 通过发现机制获取包清单,而非维护静态列表 Status: proposed @@ -14,7 +14,7 @@ Status: proposed ## 提案 -让剩余的包/门禁清单可被发现。一个唯一的权威来源——`packages/<group>/<pkg>` 层级结构加上包 manifest(元数据清单)——应当驱动 `tsconfig.build.json` 的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写出产物,`hygiene`/doc-sync(文档同步门禁)中的 `--check` 模式在提交副本陈旧时报错)。模块图生成已经在读取包 manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令而非重述第二份列表。 +让剩余的包/门禁清单可被发现。一个唯一的权威来源——`packages/<group>/<pkg>` 层级结构加上包 manifest(元数据清单)——应当驱动 `tsconfig.build.json` 的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写出产物,`hygiene`/`doc-sync`(文档同步门禁)中的 `--check` 模式在提交副本陈旧时报错)。模块图生成已经在读取包 manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令而非重述第二份列表。 层级结构不需要编码关于包的所有事实,但应当编码宽泛的维护策略:core/product 包、集成包、能力 seam 包与 support/test/example 包不应在脚本能区分它们之前先要求一份手工维护的例外列表。 diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml index dde7a0ad32..1c1c0eb757 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-unify-agent-and-session-id.md: 3a6daa411673003eb1c3017e7a717ae4bf98b735 -2026-06-20-unify-agent-and-session-id.zh.md: 6b1b996879125c6ab85aed7ba419aff15d077b42 +2026-06-20-unify-agent-and-session-id.md: 9cec898a2df9418b3533c776779c88fc50bc7dcb +2026-06-20-unify-agent-and-session-id.zh.md: 39e78db0b886d1e0b13afe2337697a184af478ed diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 3a6daa4116..9cec898a2d 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -1,9 +1,9 @@ # RFC: Unify the agent id and the session id -English | [中文](2026-06-20-unify-agent-and-session-id.zh.md) - Status: proposed +English | [中文](2026-06-20-unify-agent-and-session-id.zh.md) + ## Problem The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced and persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md index 6b1b996879..39e78db0b8 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -1,9 +1,9 @@ -# RFC:统一 agent id 与 session id - -[English](2026-06-20-unify-agent-and-session-id.md) | 中文 +# RFC: 统一 agent id 与 session id Status: proposed +[English](2026-06-20-unify-agent-and-session-id.md) | 中文 + ## 问题 agent 工厂为每个活跃的 agent/session 对维护两个 id:`agentId`(`AgentRegistry` 的路由句柄)和 `sessionId`(事件溯源与持久化日志的标识)。`CreateAgentOptions` 接收两者;`ResumeAgentOptions` 接收 `agentId` 加 `resumeSessionId`;进程内 subagent 各自铸造两个独立的 UUID,尽管血缘关系另行记录。 diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 4420b2dd27..d828a634b5 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-dead-core-spine-surface.md: c46fe464e8627dcfc39a1d3fbb38a9cbd84269cf -2026-07-04-prune-dead-core-spine-surface.zh.md: 67e89a580b086a08aed702d9e0b87bdb6e32c944 +2026-07-04-prune-dead-core-spine-surface.md: 59bbfa181a08b998c52ef63afbc63fd5226294a6 +2026-07-04-prune-dead-core-spine-surface.zh.md: 953cb3bc7b30affd505564ac427632732dd9374e diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index c46fe464e8..59bbfa181a 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -1,9 +1,9 @@ # RFC: Prune dead public and result surface -English | [中文](2026-07-04-prune-dead-core-spine-surface.zh.md) - Status: proposed +English | [中文](2026-07-04-prune-dead-core-spine-surface.zh.md) + ## Problem Several package-root exports, result fields, and convenience methods have no production consumer. They survive because tests import internals through public entry points or because a type anticipated a caller that never arrived. Each item is small in isolation, but together they enlarge the SDK contract, generated catalogs, documentation, and regression matrix without enabling a shipped path. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 67e89a580b..953cb3bc7b 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -1,4 +1,4 @@ -# RFC:裁剪无用的公开与结果接口 +# RFC: 裁剪无用的公开与结果接口 Status: proposed diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml index 2e91f9e9c2..ff2ae238ba 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-simplify-session-log-representation.md: 52720231d6e4cbe0cbb412332cd016ba63f83569 -2026-07-12-simplify-session-log-representation.zh.md: 1286a7d3c571fac66310a613c548920c3f25812d +2026-07-12-simplify-session-log-representation.md: dd8e7f319098bcdca9a844f5665583a3aa25ae80 +2026-07-12-simplify-session-log-representation.zh.md: c759b87bbb13903744a8f6bbb139ab71e1c0f39b diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md index 52720231d6..dd8e7f3190 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md @@ -1,9 +1,9 @@ # RFC: Simplify session-log representation -English | [中文](2026-07-12-simplify-session-log-representation.zh.md) - Status: proposed +English | [中文](2026-07-12-simplify-session-log-representation.zh.md) + ## Problem The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas. diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md index 1286a7d3c5..c759b87bbb 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md @@ -1,9 +1,9 @@ -# RFC:简化会话日志表示 - -[English](2026-07-12-simplify-session-log-representation.md) | 中文 +# RFC: 简化会话日志表示 Status: proposed +[English](2026-07-12-simplify-session-log-representation.md) | 中文 + ## 问题 会话日志维护着两种表示,其机制复杂度超出了消费方的实际需求:一个伪链表 surface 和自定义的请求头增量。 diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml index 13dc3e72b9..4c6aa9f685 100644 --- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-deterministic-and-stress-testing.md: e4ed7043d1880b55dd7d77b3e09a81dd58739a70 -2026-06-11-deterministic-and-stress-testing.zh.md: 4e4ee9d28025a43349b702e399096535539a91d2 +2026-06-11-deterministic-and-stress-testing.md: c629567fd16158a0bd081e7e7850fb3e6d5f1631 +2026-06-11-deterministic-and-stress-testing.zh.md: aaa170725f9d3d2457d6f418dce7bc53feda2ab7 diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md index e4ed7043d1..c629567fd1 100644 --- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.md @@ -1,9 +1,9 @@ # RFC: Deterministic tests, the replay invariant fixture, and race stress -English | [中文](2026-06-11-deterministic-and-stress-testing.zh.md) - Status: proposed +English | [中文](2026-06-11-deterministic-and-stress-testing.zh.md) + ## Problem Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt that wastes agent cycles on retries and can mask ordering bugs. Separately, our core architectural promise (any session log replays to identical derived history) is asserted in two tests but is cheap to assert *everywhere*. And the inbox wakeup race was verified by hand exactly once; nothing re-verifies it continuously. diff --git a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md index 4e4ee9d280..aaa170725f 100644 --- a/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md +++ b/docs/rfc/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md @@ -1,12 +1,12 @@ -# RFC:确定性测试、回放不变式 fixture 与竞态压力测试 - -[English](2026-06-11-deterministic-and-stress-testing.md) | 中文 +# RFC: 确定性测试、回放不变式 fixture 与竞态压力测试 Status: proposed +[English](2026-06-11-deterministic-and-stress-testing.md) | 中文 + ## 问题 -若干 agent loop(智能体循环)测试通过 `setTimeout(30)` 睡眠来同步——这是一笔不稳定性债务,浪费 agent 的重试周期,还可能掩盖时序 bug。另外,我们的核心架构承诺(任何会话日志回放后都能得到相同的派生历史)目前只在两个测试中断言,但在**所有**测试中断言的成本极低。此外,inbox 唤醒竞态只被手动验证过一次,没有任何机制持续复验。 +若干 agent loop(智能体循环)测试通过 `setTimeout(30)` 睡眠来同步——这是一笔不稳定性债务,浪费 agent 的重试周期,还可能掩盖时序 bug。另外,我们的核心架构承诺(任何会话日志回放后都能得到相同的派生历史)目前只在两个测试中断言,但在*所有*测试中断言的成本极低。此外,inbox 唤醒竞态只被手动验证过一次,没有任何机制持续复验。 ## 提案 diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml index 3bf5967b45..ce9a12799f 100644 --- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-mutation-testing.md: 344263d1c91a5e6c83320f367bf76ed6f7ef5a49 -2026-06-11-mutation-testing.zh.md: 28bb7253c12827dbcddd141481f26f60b3a72b7a +2026-06-11-mutation-testing.md: 20b24de385b944c27f4bdc0fc70f335d827f50a0 +2026-06-11-mutation-testing.zh.md: 780d3417cce48ee19ac8e3dc3b74d78b8e2a4c0f diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md index 344263d1c9..20b24de385 100644 --- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.md @@ -1,9 +1,9 @@ # RFC: Mutation testing as the coverage counterweight -English | [中文](2026-06-11-mutation-testing.zh.md) - Status: proposed +English | [中文](2026-06-11-mutation-testing.zh.md) + ## Problem The per-file 100% coverage gate ([the quality-gates decision](../../implemented/process/2026-06-11-quality-gates.md)) proves every line *executes* under test — not that any assertion would notice if the line were wrong. Under agent-written tests, coverage pressure can produce execution-without-assertion. Mutation testing measures what coverage cannot: whether the suite *kills* deliberately injected bugs. diff --git a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md index 28bb7253c1..780d3417cc 100644 --- a/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md +++ b/docs/rfc/proposed/testing/2026-06-11-mutation-testing.zh.md @@ -1,9 +1,9 @@ -# RFC:变异测试作为覆盖率的制衡手段 - -[English](2026-06-11-mutation-testing.md) | 中文 +# RFC: 变异测试作为覆盖率的制衡手段 Status: proposed +[English](2026-06-11-mutation-testing.md) | 中文 + ## 问题 逐文件 100% 覆盖率门禁([质量门禁决策](../../implemented/process/2026-06-11-quality-gates.md))证明每一行代码在测试中都被*执行*了,但不能证明如果该行出错,任何断言会注意到。在 agent(智能体)编写测试的场景下,覆盖率压力可能产出「执行但不断言」的测试。变异测试衡量的正是覆盖率无法衡量的:测试套件是否能*杀死*被刻意注入的缺陷。 diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml index f7c21ad005..31b046d91a 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-immutable-public-surfaces.md: 68472d9817f0de22777314927f05f90949903723 -2026-06-11-immutable-public-surfaces.zh.md: 7dc42ef9ff07682c1bbac1ca61caba49596cb7bc +2026-06-11-immutable-public-surfaces.md: c807b036bb57bd5e64290fd59bf422c4732e9080 +2026-06-11-immutable-public-surfaces.zh.md: 9ff40436915e194848ba163ed80fefe84a1b3da4 diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md index 68472d9817..c807b036bb 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md @@ -1,9 +1,9 @@ # RFC: Deep-readonly public surfaces -English | [中文](2026-06-11-immutable-public-surfaces.zh.md) - Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). +English | [中文](2026-06-11-immutable-public-surfaces.zh.md) + ## Problem The rejected proposal targeted an ownership hole that a `readonly SessionEvent[]` type alone cannot close: its elements remain mutable at runtime, so a cast or plain JavaScript can rewrite nested history. The implemented design closes that hole in `Session` by materializing and deep-freezing every accepted event and returning frozen array snapshots. In-flight prompt waterfalls remain intentionally transformable, so immutability is an ownership boundary rather than a blanket type rule. diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md index 7dc42ef9ff..9ff4043691 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md @@ -1,9 +1,9 @@ -# RFC:深度只读的公开接口 +# RFC: 深度只读的公开接口 + +Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). [English](2026-06-11-immutable-public-surfaces.md) | 中文 -Status: rejected — 全面使用 `DeepReadonly<T>` 类型翻转的方案已被替换为 `Session` 中由源拥有的运行时不可变性加关系型开发断言。见[源拥有的会话不可变性与开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)。 - ## 问题 被否决的提案针对的是一个所有权漏洞:仅靠 `readonly SessionEvent[]` 类型无法封堵该漏洞,因为其元素在运行时仍然可变,类型强制转换或纯 JavaScript 代码可以改写嵌套的历史记录。已实现的设计在 `Session` 中封堵了这一漏洞:对每个被接受的事件进行物化并深度冻结,返回冻结的数组快照。进行中的 prompt waterfall(瀑布式事件)有意保持可变换,因此不可变性是一条所有权边界,而非一条全局类型规则。 @@ -14,7 +14,7 @@ Status: rejected — 全面使用 `DeepReadonly<T>` 类型翻转的方案已被 在类型层面为「突变即损坏」的场景引入不可变性: -- `SessionEvent` 数据在从会话**输出**时(`events`、`session/event` 监听器)变为 `DeepReadonly`;`append()` 仍接受普通可变输入。一个 `DeepReadonly<T>` 工具类型放在 dsh-llm 中,与 brand/never 辅助类型相邻。 +- `SessionEvent` 数据在从会话输出时(`events`、`session/event` 监听器)变为 `DeepReadonly`;`append()` 仍接受普通可变输入。一个 `DeepReadonly<T>` 工具类型放在 dsh-llm 中,与 brand/never 辅助类型相邻。 - `deriveMessages()` 返回深度只读的消息;agent loop(智能体循环)在将可变请求交给 `agent/request` waterfall 之前先克隆(该处的突变是被允许的——克隆使边界显式且代价低廉,每个步骤仅一次)。 - `PromptAssembly` 在其 waterfall 流经期间保持可变(被允许),但注册表内部的 section 列表在每次组装时被克隆(已有此行为)。 diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml index 11ed0efa0b..e4d9370aa8 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-providerless-example-base.md: be5122ec6665dcea15619f3cb4b3ed3a2fa03972 -2026-06-20-providerless-example-base.zh.md: f01451f719f0fe1bbb50806aea40086e7fe08350 +2026-06-20-providerless-example-base.md: ca9d391172067aca980b5b0fbc17141320c6839e +2026-06-20-providerless-example-base.zh.md: fb64e4a0b5295e0d56e7cd598c230f56219ba77e diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md index be5122ec66..ca9d391172 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md @@ -1,9 +1,9 @@ # RFC: Make the shared example base providerless -English | [中文](2026-06-20-providerless-example-base.zh.md) - Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-spine-demo` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. +English | [中文](2026-06-20-providerless-example-base.zh.md) + ## Problem The examples had two shared base files: `examples/base-core.yml` was providerless, while `examples/base.yml` included that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result was a naming inversion: the file named `base.yml` was not the reusable base for all examples, while the true base was `base-core.yml`. diff --git a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md index f01451f719..fb64e4a0b5 100644 --- a/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.zh.md @@ -1,9 +1,9 @@ -# RFC:使共享示例基础配置与提供方无关 - -[English](2026-06-20-providerless-example-base.md) | 中文 +# RFC: 使共享示例基础配置与提供方无关 Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-spine-demo` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. +[English](2026-06-20-providerless-example-base.md) | 中文 + ## 问题 示例曾有两个共享基础文件:`examples/base-core.yml` 与提供方无关,而 `examples/base.yml` 在该核心基础上加入了真实的 `llm-deepseek` 适配器。快照回放需要与提供方无关的核心配合 `llm-replay` 使用,因为在没有密钥的情况下加载真实适配器会抛出异常。常规演示则需要真实适配器。结果是命名与实际含义倒挂:名为 `base.yml` 的文件并非所有示例可复用的基础,而真正的基础反倒是 `base-core.yml`。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml index ec03777980..c203585372 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-assembled-assistant-messages-only.md: 7605f286cf5914a127f5f8e2b77490648b42cc30 -2026-06-20-assembled-assistant-messages-only.zh.md: 05f15ffadba4607bc64fdf2f7eefdbcc41cf03a3 +2026-06-20-assembled-assistant-messages-only.md: 48f45ba08f77e2efd79bd999e262afad313bbdfe +2026-06-20-assembled-assistant-messages-only.zh.md: 44d94b3bc3d9c9f0f6c3bb8a2554664c4f5f5c59 diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md index 7605f286cf..48f45ba08f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md @@ -1,9 +1,9 @@ # RFC: Persist assembled assistant messages, not stream chunks -English | [中文](2026-06-20-assembled-assistant-messages-only.zh.md) - Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. +English | [中文](2026-06-20-assembled-assistant-messages-only.zh.md) + ## Problem The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The [session persistence RFC](../../implemented/architecture/2026-06-14-session-persistence.md) chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace. diff --git a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md index 05f15ffadb..44d94b3bc3 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md @@ -1,9 +1,9 @@ -# RFC:仅持久化组装后的 assistant 消息,不存储流式分片 - -[English](2026-06-20-assembled-assistant-messages-only.md) | 中文 +# RFC: 仅持久化组装后的 assistant 消息,不存储流式分片 Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. +[English](2026-06-20-assembled-assistant-messages-only.md) | 中文 + ## 问题 当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 RFC](../../implemented/architecture/2026-06-14-session-persistence.md) 选择这一方案是为了 token 级别的回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的 delta 记录占据,快照场景通过分组 chunk 事件来回放模型,ACP(Agent Client Protocol)加载时从 chunk 重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级别的追踪。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml index 35f0ecedd9..764ea5f4e9 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-acp-session-load.md: 39f24d313db6083db45ff6fbc4a84f504e7714cb -2026-06-20-drop-acp-session-load.zh.md: b7339f492d5f6b7e2daab5820dada0fa2b5fe823 +2026-06-20-drop-acp-session-load.md: 93a2791d10b589cfdee5ecc48922722fe27c1c8a +2026-06-20-drop-acp-session-load.zh.md: 94de0a5aa0436dbee8e78fc2dd6de72c98216768 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md index 39f24d313d..93a2791d10 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.md @@ -1,9 +1,9 @@ # RFC: Drop ACP session/load until resume has a product shape -English | [中文](2026-06-20-drop-acp-session-load.zh.md) - Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. +English | [中文](2026-06-20-drop-acp-session-load.zh.md) + ## Problem ACP advertises `loadSession: true` and implements `session/load` by injecting persistence into the bridge, validating cwd against stored metadata, reconstructing an agent from the persisted log, and replaying prior transcript updates to the client. That path has its own race handling, loading-id guard, replay presenter logic, and tests. It also depends on the canonical log retaining enough UI data to reconstruct old chunks and tool presentations. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md index b7339f492d..94de0a5aa0 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md @@ -1,9 +1,9 @@ -# RFC:移除 ACP session/load,直到 resume 具备产品形态 +# RFC: 移除 ACP session/load,直到 resume 具备产品形态 + +Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. [English](2026-06-20-drop-acp-session-load.md) | 中文 -Status: rejected — Zed 是当前目标 ACP 客户端,它声明并使用支持 load 的会话,且为并发 `session/load` 维护 pending-load 状态。bridge 应保留 `session/load` 并使 resume 契约更加稳固。 - ## 问题 ACP(Agent Client Protocol)声明 `loadSession: true` 并实现 `session/load`:向 bridge 注入持久化能力、校验 cwd 与存储元数据的一致性、从持久化日志重建 agent(智能体),并向客户端回放先前的 transcript(文本记录)更新。该路径有自己的竞态处理、loading-id 守卫、回放展示逻辑和测试。它还依赖规范日志保留足够的 UI 数据,以重建旧的分片和工具展示。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index 16827c901b..81bb11c433 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-acp-terminal-meta.md: 4ae3b31824ece850da0ddbc004e97b21e3cb9aad -2026-06-20-drop-acp-terminal-meta.zh.md: f5b3e0a4e1f37445a0b0dcbf7426806a1de89763 +2026-06-20-drop-acp-terminal-meta.md: e52187d9786ed44ef4b60aedf5396c19a2e8e872 +2026-06-20-drop-acp-terminal-meta.zh.md: a5f4d7bc1f9d3d050c23990e89610d3eaed5bf70 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md index 4ae3b31824..e52187d978 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md @@ -1,9 +1,9 @@ # RFC: Drop ACP terminal `_meta` rendering -English | [中文](2026-06-20-drop-acp-terminal-meta.zh.md) - Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. +English | [中文](2026-06-20-drop-acp-terminal-meta.zh.md) + ## Problem The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index f5b3e0a4e1..a5f4d7bc1f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -1,9 +1,9 @@ -# RFC:移除 ACP 终端 `_meta` 渲染 +# RFC: 移除 ACP 终端 `_meta` 渲染 + +Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. [English](2026-06-20-drop-acp-terminal-meta.md) | 中文 -Status: rejected — Zed 是当前目标客户端,终端 `_meta` 约定是有意为之的 Zed UX 设计,同时为其他客户端提供纯 ACP(Agent Client Protocol)回退路径。 - ## 问题 ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml index de324b8de2..304c4a62ac 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-bash-output-spill-files.md: bbc26c645cefb1659012ca0debda78fc6850d276 -2026-06-20-drop-bash-output-spill-files.zh.md: 43b6029a68542d03027b61424a2a3024ace200d5 +2026-06-20-drop-bash-output-spill-files.md: 939f99072eace71405ae96e270d5438e75713c1c +2026-06-20-drop-bash-output-spill-files.zh.md: 4a868b1971dc3abcb4a9d0442ffc6e74f5246d00 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md index bbc26c645c..939f99072e 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.md @@ -1,9 +1,9 @@ # RFC: Drop bash full-output spill files -English | [中文](2026-06-20-drop-bash-output-spill-files.zh.md) - Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. +English | [中文](2026-06-20-drop-bash-output-spill-files.zh.md) + ## Problem `dsh-bash-local` keeps bounded in-memory output and spills large stdout/stderr streams into private temp files. That requires a private directory, random owner-only file creation, close-failure handling, byte-offset incremental reads, lossy read reporting, path rendering in model-facing text, and cleanup discipline. The tool then tells the model to read a local spill path when output was truncated. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md index 43b6029a68..4a868b1971 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md @@ -1,9 +1,9 @@ -# RFC:移除 bash 完整输出溢出文件 - -[English](2026-06-20-drop-bash-output-spill-files.md) | 中文 +# RFC: 移除 bash 完整输出溢出文件 Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. +[English](2026-06-20-drop-bash-output-spill-files.md) | 中文 + ## 问题 `dsh-bash-local` 在内存中保留有界的输出,并将大体量的 stdout/stderr 流溢出到私有临时文件。这要求一个私有目录、仅所有者可写的随机文件创建、关闭失败处理、基于字节偏移的增量读取、有损读取报告、在面向模型的文本中渲染路径,以及清理纪律。当输出被截断时,该工具会告知模型去读取一个本地溢出路径。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml index 7396bcf5aa..131544ab6d 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-durable-step-boundaries.md: fba8ad4211db69d3a04d253fd9544caca0f538c6 -2026-06-20-drop-durable-step-boundaries.zh.md: e389c5506b03a472c74853f3b73c21681f96287f +2026-06-20-drop-durable-step-boundaries.md: 16fdf17c3bc8907745df17e8a105d7978eafb274 +2026-06-20-drop-durable-step-boundaries.zh.md: 5613a84d5f1b5cf87109a2e04a4cb350ffd650a8 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md index fba8ad4211..16fdf17c3b 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md @@ -1,9 +1,9 @@ # RFC: Drop durable step boundary events -English | [中文](2026-06-20-drop-durable-step-boundaries.zh.md) - Status: rejected — `step/end` is the durable indication that a model step finished, and keeping the symmetric `step/start` / `step/end` pair makes crash repair, invariants, and transcript inspection clearer than inferring completion from adjacent step-scoped events. +English | [中文](2026-06-20-drop-durable-step-boundaries.zh.md) + ## Problem The session log stores `step/start` and `step/end` events even though every step-scoped event already carries `{ turn, step }`: assistant chunks, assistant messages, tool calls, tool results, usage, and errors. `deriveMessages()` ignores step boundaries, ACP ignores them for UI, and the main consumers are invariants, tests, snapshot goldens, and crash repair. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md index e389c5506b..5613a84d5f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -1,4 +1,4 @@ -# RFC:移除持久化的步骤边界事件 +# RFC: 移除持久化的步骤边界事件 Status: rejected — `step/end` is the durable indication that a model step finished, and keeping the symmetric `step/start` / `step/end` pair makes crash repair, invariants, and transcript inspection clearer than inferring completion from adjacent step-scoped events. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml index f2aab138ee..3302d9bef1 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-unused-session-lineage.md: 4200532726a27e257e09f927240846f20a0b30ad -2026-06-20-drop-unused-session-lineage.zh.md: 1524987111f12a9c6e2014723bb1cb4c87bcf940 +2026-06-20-drop-unused-session-lineage.md: 5f76baf33fa50fc1aff9a0ab43262f063aa8e51b +2026-06-20-drop-unused-session-lineage.zh.md: 79decbb40d93f0798189d4a131197db132549f1c diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md index 4200532726..5f76baf33f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.md @@ -1,9 +1,9 @@ # RFC: Drop unused session lineage metadata -English | [中文](2026-06-20-drop-unused-session-lineage.zh.md) - Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. +English | [中文](2026-06-20-drop-unused-session-lineage.zh.md) + ## Problem `SessionHeader.parentSession` records the session a new session was forked from. It is defined in `dsh-session`, preserved by persistence backends, copied through resume, documented as lineage metadata, and covered by round-trip tests. The repo has no production fork UI or sub-agent flow that reads it. The planned sub-agent/fork seam is still a TODO, so the field is currently stored future shape. diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md index 1524987111..79decbb40d 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md @@ -1,9 +1,9 @@ -# RFC:移除未使用的会话血缘元数据 +# RFC: 移除未使用的会话血缘元数据 + +Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. [English](2026-06-20-drop-unused-session-lineage.md) | 中文 -Status: rejected — `parentSession` 是已文档化的 fork/subagent seam 的一部分,且已被 agent(智能体)/session 恢复路径保留。该字段面向未来,但并非意外的死状态。 - ## 问题 `SessionHeader.parentSession` 记录新会话从哪个会话 fork 而来。它在 `dsh-session` 中定义,被持久化后端保留,在恢复流程中复制,作为血缘元数据被文档记录,并有往返测试覆盖。然而仓库中没有任何生产环境的 fork UI 或 subagent 流程读取它。计划中的 subagent/fork seam 仍是 TODO,因此该字段目前只是预存的未来形状。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml index 8442da7f7e..adb3407f9f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-fold-session-persistence-interface.md: 695cd679c67f3e0a9f901c671c33e9507ecbe279 -2026-06-20-fold-session-persistence-interface.zh.md: 38f79f833fb5d95e4d9f392de627ee16b17cb997 +2026-06-20-fold-session-persistence-interface.md: 3e9bb277ccd0b6319081cd1aac289db764f41e58 +2026-06-20-fold-session-persistence-interface.zh.md: 13a8945fcaa5529f6d4dc156b0dc1016ab6da62d diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md index 695cd679c6..3e9bb277cc 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.md @@ -1,9 +1,9 @@ # RFC: Fold the persistence interface into dsh-session -English | [中文](2026-06-20-fold-session-persistence-interface.zh.md) - Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. +English | [中文](2026-06-20-fold-session-persistence-interface.zh.md) + ## Problem `dsh-session-persistence` is an interface package whose main concepts are already owned by `dsh-session`: `SessionHeader`, `SessionEvent`, `SessionId`, `session/event`, and `session/flush`. The package adds the abstract `SessionPersistence` service, the shared write coordinator, and contract helpers. Backend packages depend on it, and `agent-loop` has to optionally find a sibling service for resume. diff --git a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md index 38f79f833f..13a8945fca 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -1,4 +1,4 @@ -# RFC:将持久化接口合并进 dsh-session +# RFC: 将持久化接口合并进 dsh-session Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml index 3af32eacdd..83244e5741 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-generic-tool-rendering.md: 07102ed3b12d7f587b1d12f0e240c1202a2e1cdd -2026-06-20-generic-tool-rendering.zh.md: d2c8c745f0ac04a01eb72b50fd1b63cb655afe36 +2026-06-20-generic-tool-rendering.md: 77d06968a24211835d1ff5db2541efdeb8227878 +2026-06-20-generic-tool-rendering.zh.md: ab032864a5304d6781d990d1f805fe9d280c1655 diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md index 07102ed3b1..77d06968a2 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md @@ -1,9 +1,9 @@ # RFC: Collapse tool-owned UI presentation -English | [中文](2026-06-20-generic-tool-rendering.zh.md) - Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. +English | [中文](2026-06-20-generic-tool-rendering.zh.md) + ## Problem Tools can define `presentCall()` and `presentResult()` callbacks that return `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flags the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal grew incrementally into a bag of optional fields. ACP then maintains pending call state to pair a result with the original args, creates replay-only presenters on `session/load`, and maps terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parses exit status back out of rendered text because the pure replay-safe presenter no longer has the structured `BashRunResult`. diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md index d2c8c745f0..ab032864a5 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -1,9 +1,9 @@ -# RFC:收拢工具自有的 UI 展示逻辑 - -[English](2026-06-20-generic-tool-rendering.md) | 中文 +# RFC: 收拢工具自有的 UI 展示逻辑 Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. +[English](2026-06-20-generic-tool-rendering.md) | 中文 + ## 问题 工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步增长为一堆可选字段。ACP(Agent Client Protocol)随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml index a944072e9f..96425f0390 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-retire-mid-turn-steering.md: bf78125ec175aa9152789bdccd1f8e8a16863a5b -2026-06-20-retire-mid-turn-steering.zh.md: a56e112df37bdeb75ca808f76beabe1fec8b1b7b +2026-06-20-retire-mid-turn-steering.md: 2c4d686942d2bfa8016bb60d55624dc59a933c1a +2026-06-20-retire-mid-turn-steering.zh.md: 2196d7d1d1f1bff39e8e0f03cf9910ab41434d2a diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md index bf78125ec1..2c4d686942 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.md @@ -1,9 +1,9 @@ # RFC: Retire mid-turn steering -English | [中文](2026-06-20-retire-mid-turn-steering.zh.md) - Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. +English | [中文](2026-06-20-retire-mid-turn-steering.zh.md) + ## Problem The agent exposes two user-message paths that look close but have different lifecycle semantics: `send()` queues a normal user turn, while `steer()` injects a message between steps of the currently running turn and falls back to `send()` when idle. That distinction leaks through the whole stack: `Agent.steer()` is public API, the session log has a durable `steering/message` event, the agent event taxonomy has `agent/steering`, the loop maintains a steering FIFO beside the queued-message FIFO, cancellation clears both queues, and `deriveMessages()` has to render steering as a tagged synthetic user message rather than a normal prompt. diff --git a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md index a56e112df3..2196d7d1d1 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md @@ -1,9 +1,9 @@ -# RFC:移除轮次中途引导 - -[English](2026-06-20-retire-mid-turn-steering.md) | 中文 +# RFC: 移除轮次中途引导 Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. +[English](2026-06-20-retire-mid-turn-steering.md) | 中文 + ## 问题 agent(智能体)暴露了两条用户消息路径,外观相近但生命周期语义不同:`send()` 将一条普通用户轮次排入队列,而 `steer()` 在当前运行轮次的步骤之间注入一条消息,空闲时则回退为 `send()`。这一区分贯穿整个栈:`Agent.steer()` 是公开 API;会话日志有持久化的 `steering/message` 事件;agent 事件分类体系有 `agent/steering`;agent loop(智能体循环)在排队消息 FIFO 之外还维护一个 steering FIFO;取消操作需要清空两个队列;`deriveMessages()` 必须将 steering 渲染为带标签的合成用户消息,而非普通提示词。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml index 57044817c0..b3e62c79cd 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-single-session-acp-bridge.md: b7b52ca4df2d118358303745f4484e3e40a242b3 -2026-06-20-single-session-acp-bridge.zh.md: bd287f79475433e4d5e502ef30abd5d14410e633 +2026-06-20-single-session-acp-bridge.md: 8aa8f5d605154f697086dd5d432bbe9bd79c5dcd +2026-06-20-single-session-acp-bridge.zh.md: a056bc5acfab9d48259670170a964e8358df866c diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md index b7b52ca4df..8aa8f5d605 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.md @@ -1,9 +1,9 @@ # RFC: Return the ACP bridge to one live session per connection -English | [中文](2026-06-20-single-session-acp-bridge.zh.md) - Status: rejected — Zed is the current target ACP client and its ACP implementation is explicitly multi-session: it stores live sessions in a `HashMap<SessionId, AcpSession>`, tracks `pending_sessions`, joins concurrent loads for the same id, and tests close-during-load behavior. +English | [中文](2026-06-20-single-session-acp-bridge.zh.md) + ## Problem The ACP bridge now supports multiple live sessions on one JSON-RPC connection. That capability brings multi-entry session maps, reverse session/agent lookups, per-session prompt state, loading ids, demux for every event, cross-session teardown, and isolation concerns for future permission prompts and background tasks. The older [multi-session ACP proposal](../../implemented/feature/2026-06-14-acp-multi-session.md) still tracks the unfinished permission-ownership piece; this RFC is the competing simplification path. diff --git a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md index bd287f7947..a056bc5acf 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md @@ -1,9 +1,9 @@ -# RFC:将 ACP 桥接恢复为每连接一个活跃会话 +# RFC: 将 ACP 桥接恢复为每连接一个活跃会话 + +Status: rejected — Zed is the current target ACP client and its ACP implementation is explicitly multi-session: it stores live sessions in a `HashMap<SessionId, AcpSession>`, tracks `pending_sessions`, joins concurrent loads for the same id, and tests close-during-load behavior. [English](2026-06-20-single-session-acp-bridge.md) | 中文 -Status: rejected — Zed 是当前目标 ACP 客户端,其 ACP 实现明确支持多会话:它将活跃会话存储在 `HashMap<SessionId, AcpSession>` 中,跟踪 `pending_sessions`,对同一 id 的并发加载进行合并,并测试加载期间关闭的行为。 - ## 问题 ACP(Agent Client Protocol)桥接现在支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向会话/agent(智能体)查找、逐会话的 prompt 状态、加载中 id、每条事件的解复用、跨会话拆除,以及未来权限提示与后台任务的隔离问题。较早的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在追踪未完成的权限归属部分;本 RFC 是与之竞争的简化路径。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index 19a66353c7..1c341c252c 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-truncate-interrupted-turns.md: e17cb20f0185fe5d47d0a5ca18b7a389951fbadd -2026-06-20-truncate-interrupted-turns.zh.md: 48aeae650f867fb4629db33a448dd6cbaea60ae0 +2026-06-20-truncate-interrupted-turns.md: dd8475771fcd9fdd0910bd37480a50679e87911c +2026-06-20-truncate-interrupted-turns.zh.md: 7fcd7292c8c53ebf4d784cdb79a1be58960f763c diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md index e17cb20f01..dd8475771f 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.md @@ -1,9 +1,9 @@ # RFC: Truncate interrupted final turns on load -English | [中文](2026-06-20-truncate-interrupted-turns.zh.md) - Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. +English | [中文](2026-06-20-truncate-interrupted-turns.zh.md) + ## Problem The current persistence contract preserves a final turn that was durably written but never closed. On load, `interruptedTurnClosers()` scans the tail, synthesizes error `tool/result` events for unanswered tool calls, appends a `step/end` when a step is open, appends `turn/end { kind: 'interrupted' }`, and asks the backend to durably commit that repair. The coordinator, JSONL backend, SQLite backend, session event vocabulary, invariants, docs, and tests all model this synthetic close path. diff --git a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index 48aeae650f..7fcd7292c8 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/docs/rfc/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -1,4 +1,4 @@ -# RFC:加载时截断被中断的最终轮次 +# RFC: 加载时截断被中断的最终轮次 Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index 915d56d0e8..cd0677dac6 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-unimplemented-subagent-vocabulary.md: 3c86f11564d85b423fe59d784c6bf69959fb3907 -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 84ff6f15ff2c9b3a13240997ab3c7b5cf2ab7263 +2026-07-04-prune-unimplemented-subagent-vocabulary.md: 1621bc1feee8bf98478242f002d9d1dca16878f5 +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: b26ffee4764cd5a5937ef0436fa30c7c0eeba87a diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 3c86f11564..1621bc1fee 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -1,9 +1,9 @@ # RFC: Prune the unimplemented subagent seam vocabulary -English | [中文](2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md) - Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. +English | [中文](2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md) + ## Problem The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) shipped a two-tier capability design: start-time capability flags checked by the service, and optional runtime methods on `SubagentRun`. Three start-time features and both optional runtime methods have zero implementations and zero callers: diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index 84ff6f15ff..b26ffee476 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -1,9 +1,9 @@ -# RFC:裁剪未实现的 subagent seam 词汇 - -[English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 +# RFC: 裁剪未实现的 subagent seam 词汇 Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. +[English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 + ## 问题 [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:启动时由服务检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三个启动时特性和两个可选运行时方法的实现数与调用数均为零: diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 98bc1e4bab..b6d3903ddd 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-collapse-workflow-to-foreground-core.md: 78b67c10ac39fddaf4ea76ca90d5cbf760fe5866 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 28b0af0a70110d39572fafac521a555c62ebb3f5 +2026-07-12-collapse-workflow-to-foreground-core.md: eaf8a4a22766e06b743a7b91d2a607eb6c8e67d9 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 4b1f4ebbec852386ca4577a38a9bd41b7529e500 diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md index 78b67c10ac..eaf8a4a227 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -1,9 +1,9 @@ # RFC: Collapse workflows to the exercised foreground core -English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) - Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. +English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) + ## Problem The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications. diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 28b0af0a70..4b1f4ebbec 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -1,9 +1,9 @@ -# RFC:将工作流收缩至已使用的前台核心 - -[English](2026-07-12-collapse-workflow-to-foreground-core.md) | 中文 +# RFC: 将工作流收缩至已使用的前台核心 Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. +[English](2026-07-12-collapse-workflow-to-foreground-core.md) | 中文 + ## 问题 工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml index a9c24674bf..0c7d9feb75 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-prune-unused-skill-registry-surface.md: 3e8c009871c3d609612b4a01edc2048ddedfad0d -2026-07-12-prune-unused-skill-registry-surface.zh.md: deaea2ca53d4ed2ac5141013f969f621d3202875 +2026-07-12-prune-unused-skill-registry-surface.md: 5b90deca8681373b2cc2befa3ab341084f924a3d +2026-07-12-prune-unused-skill-registry-surface.zh.md: 7b0e24f49688ed61f2ac4bff93b4c3f85117a170 diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md index 3e8c009871..5b90deca86 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md @@ -1,9 +1,9 @@ # RFC: Prune unused skill registry surface -English | [中文](2026-07-12-prune-unused-skill-registry-surface.zh.md) - Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. +English | [中文](2026-07-12-prune-unused-skill-registry-surface.zh.md) + ## Problem The skill service's embedded-runtime subsystem has zero production caller of `ctx.skills.register()`. It adds a reserved `runtime` provider name, a runtime map/rank/source, duplicate policy, a second revision in cache keys, normalization, disposers, and tests alongside the provider seam every shipped skill already uses. `SkillSummary.whenToUse` and candidate/definition `path` are parsed and copied but never read by a production consumer: the model catalog renders name/description, resource loading uses `resourceBase`, and providers own their locator. The deliberately open `metadata` extension point stays. diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md index deaea2ca53..7b0e24f496 100644 --- a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md @@ -1,9 +1,9 @@ -# RFC:裁剪 skill 注册表中未使用的接口 - -[English](2026-07-12-prune-unused-skill-registry-surface.md) | 中文 +# RFC: 裁剪 skill 注册表中未使用的接口 Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. +[English](2026-07-12-prune-unused-skill-registry-surface.md) | 中文 + ## 问题 skill(技能)服务的嵌入式运行时子系统中,`ctx.skills.register()` 没有任何生产调用方。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose(资源释放)器以及相应测试——而所有已交付的 skill 都只使用提供方 seam。`SkillSummary.whenToUse` 和 candidate/definition 的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位器。有意开放的 `metadata` 扩展点保留不动。 From 01622abde7f19c2bf6cc1f1a3c772ff6f55e2fb8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:58:05 +0800 Subject: [PATCH 014/113] docs(i18n): refresh core data translations after merge --- .agents/notes/README.i18n.yaml | 4 +- .agents/notes/README.zh.md | 46 +- docs/core-data-structures/approval.i18n.yaml | 4 +- docs/core-data-structures/approval.zh.md | 26 +- docs/core-data-structures/bash.i18n.yaml | 4 +- docs/core-data-structures/bash.zh.md | 236 +++-- .../code-runtime.i18n.yaml | 4 +- docs/core-data-structures/code-runtime.zh.md | 38 +- .../core-data-structures/compaction.i18n.yaml | 4 +- docs/core-data-structures/compaction.zh.md | 52 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.zh.md | 335 ++++--- .../core-data-structures/filesystem.i18n.yaml | 4 +- docs/core-data-structures/filesystem.zh.md | 114 ++- .../llm-streaming.i18n.yaml | 4 +- docs/core-data-structures/llm-streaming.zh.md | 152 ++- .../persistence.i18n.yaml | 4 +- docs/core-data-structures/persistence.zh.md | 66 +- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.zh.md | 71 +- docs/core-data-structures/scope.i18n.yaml | 4 +- docs/core-data-structures/scope.zh.md | 30 +- .../session-query.i18n.yaml | 4 +- docs/core-data-structures/session-query.zh.md | 109 ++- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.zh.md | 407 ++++++-- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.zh.md | 47 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.zh.md | 155 +++- .../system-prompt.i18n.yaml | 4 +- docs/core-data-structures/system-prompt.zh.md | 24 +- docs/core-data-structures/tools.i18n.yaml | 4 +- docs/core-data-structures/tools.zh.md | 182 +++- .../user-interaction.i18n.yaml | 4 +- .../user-interaction.zh.md | 15 +- docs/core-data-structures/web.i18n.yaml | 4 +- docs/core-data-structures/web.zh.md | 53 +- docs/core-data-structures/workflow.i18n.yaml | 4 +- docs/core-data-structures/workflow.zh.md | 65 +- ...-acp-default-export-drops-inject.i18n.yaml | 4 +- ...0001-acp-default-export-drops-inject.zh.md | 2 +- ...ession-disabled-filesystem-tools.i18n.yaml | 4 +- ...expression-disabled-filesystem-tools.zh.md | 8 +- docs/postmortem/README.i18n.yaml | 4 +- docs/postmortem/README.zh.md | 2 +- scripts/type-equiv.manifest.json | 867 +++++++++++++++++- 47 files changed, 2685 insertions(+), 509 deletions(-) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 680bf965c7..7344c11eec 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 9014579f3a98be907885332a0c815bca5c96855c -README.zh.md: 3ed8b5a03c7f5888a331d0220a7b7c7fb6cd68e2 +README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668 +README.zh.md: b98d54ca64ed6150ddfc9f25c98ac64fe9a344f0 diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 3ed8b5a03c..b98d54ca64 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -1,32 +1,34 @@ -# RFC +# Agent Notes [English](README.md) | 中文 -这里存放一类设计文档。**RFC** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。完整列表见生成的 [INDEX.md](INDEX.md);本文件是契约:RFC 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 +这里存放一类设计文档。**Agent Note(agent 决策记录)** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件是入口和契约:Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 ## 布局与命名 -每份 RFC 有两个维度,都编码在其**路径**中:`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`。 +每份 Agent Note 有两个维度,都编码在其**路径**中:`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`。 -- **生命周期**(顶层文件夹)是 RFC 的状态,RFC 随状态变化在文件夹之间移动: +- **生命周期**(顶层文件夹)是 Agent Note 的状态,Agent Note 随状态变化在文件夹之间移动: - **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。 - - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,RFC 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 + - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 - **`rejected/`**:提案经过讨论后被否决。保留以备查阅,避免同一问题被反复争论。 - **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。 -文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。RFC 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 +文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 + +目录树就是清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md)。 <a id="classification"></a> ## 分类 -每份 RFC 属于 `scripts/rfc-index.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。[INDEX.md](INDEX.md) 由路径、标题和文件名日期生成,其新鲜度受门禁保护。新增类别需要同时更新规范集合与本节。见[分类 RFC](implemented/process/2026-06-20-rfc-classification.md) 与[索引生成 RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md)。 +每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.md)。 | 类别 | 覆盖范围 | |---|---| -| `feature` | 面向用户或模型的新能力。 | +| `feature` | 面向用户或模型的新功能。 | | `bug-fix` | 修正缺陷或弥补事故复盘(postmortem)发现的缺口。 | -| `simplification` | 在不增加能力的前提下移除代码、行为或对外表面积。 | +| `simplification` | 在不增加功能的前提下移除代码、行为或对外表面积。 | | `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 | | `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 | | `testing` | 测试基础设施与策略。 | @@ -35,22 +37,22 @@ ## 何时需要写一份 -当一个决策具备以下三个特征时,请写一份 RFC:**持久性**(它的影响超出单个函数或包)、**争议性**(存在一个合理工程师可能选择的真实替代方案)、**意外性**(未来读者否则会问「为什么要这样做」)。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 +每个非平凡变更都必须在同一 PR 中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -以下情况不要写 RFC:机械性或局部的选择(一个变量名、一次单文件重构);已由门禁或 AGENTS.md 中的约定强制执行并解释的事项;代码中标记为 `TODO(...)` 的临时决策——将其记为 TODO,待稳定后再升级为 RFC。RFC 永远不会被编辑为一个*不同的决策*:用新 RFC 取代旧的,并互相链接。(编辑 `implemented/` RFC 以跟踪其已做出的决策现在*位于*何处——移动的文件、重命名的包——不是不同的决策,这是必需的而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。) +更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 <a id="the-file-format"></a> ## 文件格式 -每份 RFC 遵循统一的文件内格式,由 `pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../scripts/verify-rfc-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 RFC](implemented/process/2026-07-05-uniform-rfc-format.md)。 +每份 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md)。 ### 头部块 -每份 RFC 的前三行严格为: +每份 Agent Note 的前三行严格为: ```markdown -# RFC: <title> +# Agent Note: <title> Status: <status> ``` @@ -61,11 +63,11 @@ Status: <status> - `Status: implemented` - `Status: rejected — <why, in one line>` -状态行不带日期、不带括号补充说明:文件名记录首次提出日期,git 记录其余一切;「以修订形式接受」之类的说明属于正文内容(在陈述决策的地方说明修订)。拒绝原因是唯一带内容的状态,因为读者查阅被否决的 RFC 时,结论正是他们要找的。 +状态行不带日期、不带括号补充说明:文件名记录首次提出日期,git 记录其余一切;「以修订形式接受」之类的说明属于正文内容(在陈述决策的地方说明修订)。拒绝原因是唯一带内容的状态,因为读者查阅被否决的 Agent Note 时,结论正是他们要找的。 ### 正文骨架 -每份 RFC 的正文以 `## Problem` 开头:动机,写法上不依赖解决方案即可独立成文。后续内容取决于生命周期;固定章节使用以下规范名称且仅限这些名称,而真正独特的技术章节(包拓扑、协议契约、schema 等)在必需章节之间可自由组织。 +每份 Agent Note 的正文以 `## Problem` 开头:动机,写法上不依赖解决方案即可独立成文。后续内容取决于生命周期;固定章节使用以下规范名称且仅限这些名称,而真正独特的技术章节(包拓扑、协议契约、schema 等)在必需章节之间可自由组织。 #### `proposed/` @@ -90,20 +92,20 @@ Status: <status> ## Consequences ``` -`## Decision` 以现在时态描述已交付的现实,整个文件按 [implemented/AGENTS.md](implemented/AGENTS.md) 的要求与之保持同步。`## Consequences` 记录权衡的代价**与**收益。提案阶段的标题在此属于规格用语,门禁会拒绝它们:`## Proposal`、`## Plan`、`## Migration plan` 和 `## Acceptance criteria` 不得出现在 implemented RFC 中(原因见 [slop 检查清单](../AGENTS.md))。`## Testing`、`## Deferred` 或 `## Related` 章节在陈述现在时态的事实时是允许的。 +`## Decision` 以现在时态描述已交付的现实,整个文件按 [implemented/AGENTS.md](implemented/AGENTS.md) 的要求与之保持同步。`## Consequences` 记录权衡的代价**与**收益。提案阶段的标题在此属于规格用语,门禁会拒绝它们:`## Proposal`、`## Plan`、`## Migration plan` 和 `## Acceptance criteria` 不得出现在 implemented Agent Note 中(原因见 [slop 检查清单](../../docs/AGENTS.md))。`## Testing`、`## Deferred` 或 `## Related` 章节在陈述现在时态的事实时是允许的。 #### `rejected/` -被否决的 RFC 是冻结的提案:保留提案时的所有章节(包括 `## Acceptance criteria` 或 `## Plan`),结论写在 `Status:` 行上。仅头部块、`## Problem` 开头、`## Proposal` 章节以及下方的「曾考虑的替代方案」强制要求适用。 +被否决的 Agent Note 是冻结的提案:保留提案时的所有章节(包括 `## Acceptance criteria` 或 `## Plan`),结论写在 `Status:` 行上。仅头部块、`## Problem` 开头、`## Proposal` 章节以及下方的「曾考虑的替代方案」强制要求适用。 ### 曾考虑的替代方案——必需 -每份 RFC 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是 RFC 存在的意义所要防止的。 +每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是这些 Agent Note 存在的意义所要防止的。 -替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 RFC,在该章节位置放置以下精确注释,门禁仅对格式规范之前的文件接受此注释: +替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 Agent Note,在该章节位置放置以下精确注释,门禁仅对格式规范之前的文件接受此注释: ```markdown -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> ``` ### 在生命周期之间移动 @@ -112,4 +114,4 @@ Status: <status> ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 契约](../i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# RFC: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 +`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 diff --git a/docs/core-data-structures/approval.i18n.yaml b/docs/core-data-structures/approval.i18n.yaml index 9a644ee85a..c6bfeafaf9 100644 --- a/docs/core-data-structures/approval.i18n.yaml +++ b/docs/core-data-structures/approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -approval.md: 772582955145092f3d483c297f7704b2b8506375 -approval.zh.md: 706613b84784d81cf112622059c755e815c23f16 +approval.md: c9b411e38508fc7e8ac2e2dd923d9c8cc2f475a3 +approval.zh.md: ee593298fece8b2782228f2e93c5509871e15166 diff --git a/docs/core-data-structures/approval.zh.md b/docs/core-data-structures/approval.zh.md index 706613b847..ee593298fe 100644 --- a/docs/core-data-structures/approval.zh.md +++ b/docs/core-data-structures/approval.zh.md @@ -8,15 +8,23 @@ ## 标识与结果 -每个请求获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 和 `approval/decided` 审计事件配对,同时确保审批 id 不会与 tool-call、session 或 agent id 混用。 +每个请求都会获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent/session id 互换。 ```ts type-equiv +/** + * Pairs one `approval/asked` audit event with its `approval/decided`. + * Service-issued (one fresh id per {@link ApprovalService.request} call). + */ type ApprovalRequestId = Branded<'ApprovalRequestId'> ``` `ApprovalOutcome` 是闭合的,且默认拒绝。`allowed-once` 仅授权所询问的那一个操作;调用方对 `rejected`、`cancelled` 和 `unavailable` 均执行拒绝。缺失、无所有权、抛异常或不合规的应答者会产生 `unavailable`,而非放行。 ```ts type-equiv +/** + * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn + * request, or unavailable answerer. Callers fail closed on `unavailable`. + */ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ``` @@ -25,6 +33,18 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' `ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 ```ts type-equiv +/** + * A session's approval policy — what happens to an {@link ApprovalService} + * ask BEFORE any interactive answerer sees it: + * + * - `'ask'` (the default) — delegate to the composed answerers; with none + * composed the chain falls through to the fail-closed `'unavailable'` + * (exactly today's behavior). + * - `'never'` — never prompt anyone: every ask resolves `'rejected'` + * deterministically. The strict headless stance (CI, unattended runs) and + * the only policy value stated in the system prompt — unlike `'ask'`, its + * outcome is knowable without asking, so stating it cannot overclaim. + */ type ApprovalPolicy = 'ask' | 'never' ``` @@ -35,6 +55,10 @@ type ApprovalPolicy = 'ask' | 'never' `ApprovalRequest` 以足够精确的方式标识 agent 和工具操作,以便路由和审计该问题。它有意省略工具参数:应答者通过 `callId` 将提示附加到已流式输出的工具调用上,而非渲染一份可能漂移的副本。 ```ts type-equiv +/** + * Readonly same-process permission question. `callId` links to an already + * presented tool call, so arguments are not duplicated here. + */ interface ApprovalRequest { /** * The agent on whose behalf the question is asked. Routes the question (a diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 601c350359..168c96a10a 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -bash.md: 7b5c779b832ef5be6591e626980f7f22db54f239 -bash.zh.md: c4d27efa8344727e3a77c928b331ec570c2716eb +bash.md: 35cf2061588907dde41123efb01e453eb9cc929d +bash.zh.md: 56e5ae7b6231575a6b591093724d5560ade27056 diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index c4d27efa83..56e5ae7b62 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,21 +2,48 @@ [English](bash.md) | 中文 -Bash 执行 seam:典型的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) 示例,拆分为三个包(package):接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local),本地子进程)和消费方([dsh-tool-bash](../../packages/bash/tool-bash),`bash`/`bash_output`/`bash_kill` 工具 schema)。Bash 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。沙箱化、容器化或远程后端是实现同一接口的兄弟包。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) -## 请求与规格:`resolve()` 拆分 +## 受管 shell 环境命名空间 -该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs` 可选,由配置填充)与**执行器实际执行的完全解析规格**(这些字段为必填)分离。工具层在二者之间调用 `ctx.bash.resolve(request)`。这是本仓库「在包边界处显式优于隐式」规则的具体体现:阅读 `BashExecSpec` 的人永远不会疑惑工作目录从何而来。 +`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。 ```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one bash execution. */ +type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>> +``` + +## 请求与规格:`resolve()` 拆分 + +该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs`/`stdoutMaxBytes` 可选,由配置或请求策略补全)与执行器实际使用的**完全解析后的 spec**(这些字段均为必填)分开。工具层在二者之间调用 `ctx.bash.resolve(request)`——这具体落实了仓库的「包 seam 上显式优于隐式」规则:`BashExecSpec` 的读者不必猜测工作目录或输出预算来自何处。 + +```ts type-equiv +/** + * A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and + * filled by {@link BashExecutor.resolve} from the implementation's config. + * This is the model-/plugin-facing shape; pass it to `resolve()` to obtain a + * fully-resolved {@link BashExecSpec}. + */ interface BashExecRequest { command: string /** Working directory override (default: implementation-configured). */ workdir?: string | undefined /** Timeout override in milliseconds (implementations cap it). */ timeoutMs?: number | undefined + /** + * Foreground stdout capture budget in bytes. Absent uses the executor's + * default output cap. Trusted in-process consumers use this when they must + * parse complete stdout up to their own bounded limit; the model-facing bash + * tool does not expose it as a parameter. + */ + stdoutMaxBytes?: number | undefined /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined /** @@ -28,115 +55,92 @@ interface BashExecRequest { */ stdin?: string | undefined /** - * Extra environment entries for the command, merged AFTER the - * implementation's credential scrub (so an explicit entry here is honored even - * when its name matches the scrub pattern — the caller named a value it holds, - * not the harness's ambient secret). Set by in-process plugins (the hooks - * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing - * bash tool does not expose it as a parameter (a model that needs an env var - * uses shell syntax like `FOO=bar cmd`). + * Ordinary environment entries for the command, merged after the credential + * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it + * here. Set by in-process plugins (the hooks bridges set + * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool + * does not expose it as a parameter. */ env?: Record<string, string> | undefined /** - * Opaque OWNER token for a background task — the consumer's isolation key - * (the tool layer passes the owning agent's `session.header.id`). The - * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf}; - * the executor itself NEVER interprets it (no access policy lives in the - * seam — that is the consumer's job). Absent for foreground runs and for an - * ownerless background start (a non-agent caller). + * Harness-owned `DSH_*` variables for this execution. Executors discard + * ambient `DSH_*` entries before merging this snapshot, so an unavailable + * current fact cannot inherit a stale value from the harness process, and + * reject non-`DSH_*` names supplied through this managed channel. */ - owner?: OwnerToken | undefined - /** - * Explicit per-call sandbox-policy input, overriding the executor's - * configured default mode for THIS call. Never a silent default: a - * consumer sets it only from an explicit policy source — an - * `'allowed-once'` grant a human just issued through `ctx.approval` (the - * escalation flow in the sandbox RFC § Escalation, which outranks), or the - * session's standing override folded from its own `bash/sandbox-mode` - * events (the sandbox RFC § Per-session mode switching — the user's recorded per-session - * choice). A sandboxing executor confines THIS call under the given mode; - * a non-sandboxing executor carries the field and confines nothing (the - * tool layer stamps neither escalation nor overrides without a sandboxing - * executor — see {@link BashExecutor.sandboxMode}). - */ - sandboxMode?: SandboxMode | undefined + dshEnv?: DshEnvironment | undefined + /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ + sandboxPolicy?: SandboxExecutionPolicy | undefined } ``` ```ts type-equiv +/** + * A resolved execution spec. {@link BashExecutor.resolve} fills and caps the + * required fields; {@link BashExecutor.start} ignores `timeoutMs` because + * background processes have no executor timeout. + */ interface BashExecSpec { command: string workdir: string timeoutMs: number + /** + * Resolved foreground stdout capture budget in bytes. `run()` uses it for + * stdout; background tasks and stderr keep the executor's own output cap. + */ + stdoutMaxBytes: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined - /** - * Bytes to write to the command's stdin (then close it), carried through - * verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec - * (unlike `owner`): it has no config default, so a missing one means "no - * stdin" — the safe, ordinary case — not a silent footgun, so it stays a - * plain optional rather than required-but-nullable (see the request field). - */ + /** Bytes to write to stdin before closing it; absent means no stdin. */ stdin?: string | undefined /** - * Extra environment entries, carried through verbatim from - * {@link BashExecRequest.env} and merged by the implementation AFTER its - * credential scrub (an explicit entry wins even when its name matches the - * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no - * config default, absent means "no extra env". + * Ordinary environment entries carried through from + * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * OPTIONAL on the spec for the same reason as `stdin`: absent means no + * ordinary extra environment. */ env?: Record<string, string> | undefined - /** - * Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs` - * being required on the resolved spec): {@link BashExecutor.resolve} carries - * the request's `owner` through, defaulting a missing one to `undefined`. A - * required field makes a forgotten owner a VISIBLE `undefined` rather than a - * silently-absent property that yields an unowned (cross-session-readable) - * task. `start()` stores it; `run()` (foreground) ignores it. - */ - owner: OwnerToken | undefined - /** - * The sandbox mode this call executes under, REQUIRED-but-nullable for the - * same visibility reason as `owner`. A sandboxing executor's `resolve()` - * stamps the effective mode (the request's explicit override, else its - * configured default) so `run()`/`start()` read the spec, never the config; - * a non-sandboxing executor carries the request value through verbatim and - * ignores it (`undefined` under such an executor means what its README says: - * unconfined execution). - */ - sandboxMode: SandboxMode | undefined + /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + dshEnv?: DshEnvironment | undefined + /** Resolved sandbox policy; ignored by executors that do not confine. */ + sandboxPolicy: SandboxExecutionPolicy | undefined } ``` -`owner` token 是隔离键:执行器存储它但从不解释它(访问策略是消费方的职责),因此一个 agent 启动的后台任务不会被跨会话读取。必填但可空的字段使遗忘的 owner 成为一个可见的 `undefined`,而非一个静默无主的任务。 +`stdin` 和 `env` 是受信任的进程内插件输入,不由 `dsh-tool-bash` 暴露。本地执行器会先清除环境中的凭据,再合并调用方显式提供的 env。见 [bash-stdin-env Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 -受信的进程内插件使用 `stdin` 和 `env` 传递钩子载荷与钩子专用变量。面向模型的 bash 工具从其命名的 schema 字段构造请求,不暴露这两个输入,因为 shell 语法本身已提供等价能力;测试防止未来出现 `...args` 展开。这是请求形状的纪律约束,而非安全边界:`dsh-bash-local` 无论这些字段如何都会清洗环境凭证,然后叠加调用方已持有的显式值。见 [bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。 - -该 seam 处理的两个 id 都是[品牌化的](core.md)(零成本 `string` 品牌,与 `SessionId`/`AgentId` 相同的机制):`BashTaskId`(被跟踪的后台任务,由本地执行器生成 `bash-N`)和 `OwnerToken`(不透明的隔离键)。`OwnerToken` 刻意是一个与 `SessionId` 不同的品牌,而非别名:bash seam 是一个能力 seam,不得知道 owner token *意味着*什么,因此它从不导入 `dsh-session` 的词汇。`dsh-tool-bash` 消费方是唯一将拥有者 agent 的 `SessionId` 转换为 `OwnerToken` 的边界。对两者施加品牌化,可以防止裸 `string`(或在需要 `OwnerToken` 的地方传入 `BashTaskId`,反之亦然)在面向模型的 `task_id` 路径上通过类型检查。 +`stdoutMaxBytes` 同样仅供受信任插件使用。它让前台消费方能在有界解析预算内请求完整 stdout,而不会改变 stderr、后台任务或面向模型的 bash 工具的常规输出上限。 ## 前台运行:`BashRunResult` 一次已完成(或被终止)的前台运行的结果。正交的结果**独立报告**:一个进程可以同时超时并以退出码 0 退出(因为它捕获了信号),因此 `timedOut`、`aborted`、`signal` 和 `exitCode` 各自独立为一个字段;调用方永远不会把一次被截断的运行误读为干净的成功。 ```ts type-equiv +/** The outcome of one completed (or killed) foreground run. */ interface BashRunResult { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - /** True when the executor's own timeout killed the command. */ + /** + * True when the executor's own timeout was the FIRST cause to cut the command + * short. Mutually exclusive with {@link aborted}: one fused deadline drives + * both the timeout and the caller's cancellation, so a timeout and an abort + * racing before process close report the single first-abort cause, not both + * (see the [timeout-library Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). + */ timedOut: boolean - /** True when the caller's AbortSignal killed the command. */ + /** + * True when the caller's `AbortSignal` was the FIRST cause to kill the command + * (and it was not the executor's own timeout). Mutually exclusive with + * {@link timedOut} — see there for the first-cause classification. + */ aborted: boolean /** The effective timeout applied to this run (after defaulting/capping). */ timeoutMs: number stdout: CollectedOutput stderr: CollectedOutput - /** - * Sandbox facts, present iff a sandboxing executor ran the command — an - * unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See - * {@link BashSandboxInfo} for the `denied` classification semantics. - */ + /** Sandbox execution facts, absent for an unsandboxed executor. */ sandbox?: BashSandboxInfo } ``` @@ -144,6 +148,7 @@ interface BashRunResult { 每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件: ```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ interface CollectedOutput { /** Collected text — the TAIL of the stream when truncated. */ text: string @@ -156,79 +161,70 @@ interface CollectedOutput { ## 文件沙箱:`BashSandboxInfo` -消费沙箱的执行器(`dsh-bash-sandbox`)通过 `BashExecutor.sandboxMode` 暴露其配置的回退模式。工具层折叠每个 agent 会话的持久 `bash/sandbox-mode` 覆盖,将生效模式印到请求上,并可为一次用户批准的严格更宽调用替换它。它刻意既不声明当前模式也不叙述切换过程;拒绝结果会指明该命令实际运行时所处的模式。模式/执行词汇由 [`@deepseek-ai/dsh-sandbox` seam](sandbox.md) 拥有并编目,其提供方包装执行器的 argv;模式仅管控文件效果,不涉及网络或进程可见性。 +消费 sandbox 的执行器通过 `BashExecutor.sandboxMode` 暴露其已配置的模式回退值。工具层请求 [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md),把每个调用会话的持久 `sandbox/mode` 覆盖值与不可变 cwd 解析为 `BashExecRequest.sandboxPolicy`;经用户批准、严格更宽松的调用只替换模式。模式/root/enforcement 词汇归 [`@deepseek-ai/dsh-sandbox` seam](sandbox.md) 所有;模式仅管辖文件效果。 -沙箱化运行始终在 `BashRunResult.sandbox` 上报告其执行时的事实:`denied` 是执行器对失败的保守分类——判定为沙箱导致(退出失败且 stderr 携带文件系统权限特征——从不是干净退出或信号终止),从收集到的 stderr 尾部读取;`enforcement` 报告所选后端对该模式文件效果的管控完整程度(`SandboxEnforcement = 'full' | 'partial'`:`partial` 表示较旧的 Landlock ABI 仅管控所请求访问的子集;`danger-full-access` 下不存在此字段,因为没有任何限制);`runnerFailed` 标记与拒绝相反的情况——沙箱运行器本身失败、命令从未执行(仅在已结算的后台任务上标记;前台运行通过抛出 `SANDBOX_UNAVAILABLE` 错误暴露同一状况): +sandbox 化运行会报告其模式、保守的拒绝分类与强制执行完整度。`runnerFailed` 标记命令运行前 sandbox runner 已失败;前台执行会抛出 `SANDBOX_UNAVAILABLE`,而已结束的后台进程只能通过其事实通道报告。 ```ts type-equiv +/** + * Sandbox facts for one run, present iff a sandboxing executor handled it. + * Facts are reported independently of process exit status so callers can + * distinguish command failures from policy denials and runner failures. + */ interface BashSandboxInfo { /** The mode the command actually ran under. */ mode: SandboxMode - /** - * True when the executor classifies this run's failure as the sandbox - * denying a file operation. The classification is CONSERVATIVE (a failed - * exit whose stderr carries a filesystem-permission signature) and reads - * the COLLECTED stderr — the bounded in-memory tail per - * {@link CollectedOutput} semantics, so a signature that survives only in a - * spill file is missed toward `denied: false`. A plain command failure - * keeps `denied: false` even under a sandboxed mode. - */ + /** Whether the sandbox denied a file operation. */ denied: boolean - /** - * How completely the runner enforced `mode`'s file effects — see - * {@link SandboxEnforcement}. Absent exactly when `mode` is - * `danger-full-access`: nothing is confined, so there is no enforcement to - * report. - */ + /** How completely the selected runner enforced the requested mode. */ enforcement?: SandboxEnforcement - /** - * True when the executor classifies this failure as the SANDBOX RUNNER - * itself failing (missing binary, refused profile, fail-closed refusal - * before exec) — the command NEVER RAN; this is a sandbox failure, not a - * task failure, and it outranks `denied` (a runner's own error text can - * contain denial words). Only ever stamped on settled BACKGROUND tasks: a - * foreground run surfaces the same condition as the thrown - * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error - * channel; a settled task's facts are its only channel). - */ + /** Whether the sandbox runner failed before the command could run. */ runnerFailed?: boolean } ``` -还有一个词汇完成整幅图景:`SANDBOX_UNAVAILABLE` 错误码(由 [sandbox seam](sandbox.md) 拥有)是 `ctx.sandbox` 提供方在受限模式没有可用后端时抛出的错误,执行器将其传播。所选运行器拒绝其 profile 时也触发同一快速失败的前台错误;已结算的后台任务则记录 `runnerFailed`。模型在结果中接收拒绝/运行器事实,仅在拒绝标记指明模式时才获知生效模式,并可通过 `sandbox_permissions` 加 `justification` 请求一次严格更宽的重试;`ctx.approval` 必须在任何执行之前批准该确切调用。完整的策略与切换设计见 [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md)。 +最后一项补全了这套词汇:当受限模式没有可用后端时,`ctx.sandbox` 提供方会抛出、执行器会传播由 [sandbox seam](sandbox.md) 所有的 `SANDBOX_UNAVAILABLE` 错误码。选定的 runner 拒绝其 profile 时会触达同一个故障关闭的前台错误;已结束的后台任务则记录 `runnerFailed`。模型会在结果中收到拒绝/runner 事实,仅当拒绝标记指出生效模式时才得知该模式,并可通过 `sandbox_permissions` 加 `justification` 请求一次性、严格更宽松的重试;执行任何操作前,`ctx.approval` 必须批准该次确切调用。完整的策略与切换设计见 [sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 -## 后台任务:`BashTask` +## 后台进程:`BashProcess` -通过 `start()` 启动的长时间运行命令被跟踪为 `BashTask`。`BashTaskStatus` 为 `'running' | 'completed' | 'killed'`;`done` 在底层进程关闭时 resolve,从不 reject。沙箱化执行器在任务结算后标记 `sandbox`(分类针对已结算任务收集到的 stderr 运行),因此该字段在运行中以及非沙箱化执行器下不存在。 +`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.tasks.start()` hooks;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时 resolve 且绝不 reject;进程结束后仍可读取,并且 sandbox 事实会在 `done` resolve 前写入。 ```ts type-equiv -interface BashTask { - readonly id: BashTaskId - status: BashTaskStatus +/** + * A background process handle returned by {@link BashExecutor.start}. It is the + * only access path; buffered output remains readable after exit. Executor + * disposal kills running processes and awaits {@link done}. + */ +interface BashProcess { + /** Process lifecycle state (settled exactly once). */ + status: BashProcessStatus /** Exit code once finished (null = killed by signal / still running). */ exitCode: number | null /** Terminating signal name, when signal-killed. */ signal: NodeJS.Signals | null - /** Resolves when the underlying process closes (never rejects). */ + /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */ readonly done: Promise<void> - /** - * Sandbox facts for this task's execution, stamped by a sandboxing executor - * once the task settles and BEFORE completion listeners are notified — an - * `onTaskDone` consumer and a `done` awaiter both see it. Denial - * classification runs against the settled task's collected stderr, so the - * field cannot exist earlier: absent while the task is running and under an - * executor that does not sandbox. See {@link BashSandboxInfo} for the - * `denied` semantics. - */ + /** Sandbox facts, stamped once a confined process settles. */ sandbox?: BashSandboxInfo + /** + * Read output produced since the previous read (consuming — consecutive + * reads never re-deliver). Reads that lost data flag `lossy` and point at + * full-stream spill files when available. + */ + readOutput(): BashProcessRead + /** + * Kill the process group. Returns false when it had already finished + * (no-op); idempotent. + */ + kill(): boolean } ``` -`readOutput()` 返回增量的 `BashTaskRead`:自上次读取以来产生的输出,附带一个 `lossy` 标志指示截断是否丢弃了未读字节: +`readOutput()` 返回增量 delta 与 spill 恢复事实: ```ts type-equiv -interface BashTaskRead { - task: BashTask +/** One incremental {@link BashProcess.readOutput} read. */ +interface BashProcessRead { /** Output produced since the previous read (stderr in a marked section). */ delta: string /** True when truncation dropped unread bytes the delta cannot include. */ @@ -242,4 +238,4 @@ interface BashTaskRead { ## 服务 -`BashExecutor`(`ctx.bash`,抽象——定义于 [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts))遵循 `LlmService`/`LlmAdapter` 的拆分模式:`resolve`(请求→规格)、`run`(前台)、`start`(后台)、`get`/`ownerOf`/`list`/`readOutput`/`kill`,以及 `onTaskDone`(`BashTaskListener` 完成回调)。spawn 的命令获得一个**清洗后的 env**(丢弃 `*KEY*`/`*SECRET*`/`*TOKEN*`),溢出文件使用一个权限为 0700 的私有目录(随机文件名、仅所有者可打开)。模型输出永远不会获得环境变量或可预测路径。提供这一切的实现是 `dsh-bash-local`;调用它的面向模型的 `bash`/`bash_output`/`bash_kill` schema 位于 `dsh-tool-bash`(并通过[工具展示词汇](tools.md#tool-presentation-ui-vocabulary)作为终端呈现)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose 后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index 918b3f5738..21bf3ee3e0 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -code-runtime.md: 28152947d0853fb10228c472ca3e121e77b7b598 -code-runtime.zh.md: 7d5750e0c245ba97ed7ece6083464382109e10fd +code-runtime.md: a984c0f6422defc879086ff95eb94048aaa6e285 +code-runtime.zh.md: c3989fa10814139fefe91e99d9b050c1e851f8ed diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index 7d5750e0c2..c3989fa108 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -2,7 +2,7 @@ [English](code-runtime.md) | 中文 -代码执行 seam:一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)运行一段模型编写的程序,对接宿主提供的异步绑定,并报告程序的打印输出与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。后端因执行基底和源语言而异,二者都是服务上的只读描述符;worker-thread 后端与工具注册表消费方(Code Mode)在 [Code Mode RFC](../rfc/implemented/feature/2026-06-15-code-mode.md) 中定义。 +代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)针对宿主提供的异步 binding 运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此而非 [core.md](core.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread 后端和工具注册表消费方(Code Mode)由 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定。 源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) @@ -11,6 +11,12 @@ `CodeRunRequest` 携带**运行时所需的一切**。按照「包(package)边界处显式优于隐式」的规则,默认值(时间预算、输出上限)来自实现的已校验配置,绝不是 `run()` 内部隐藏的 `??`: ```ts type-equiv +/** + * One run: the program source plus everything the runtime acts on. Per the + * explicit-over-implicit convention, defaulting (time budgets, output caps) + * is the implementation's validated config — a request carries no optional + * tuning knobs for a hidden `??` to fill in. + */ interface CodeRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It @@ -33,6 +39,11 @@ interface CodeRunRequest { 结果将错误报告为一个**字段**,而非 `run()` 的 rejection。报告失败的程序是调用方的职责,不走异常路径(与 `BashExecutor.run` 的 resolve-on-failure 契约一致): ```ts type-equiv +/** + * The outcome of one run. An error is a FIELD on a resolved result, never a + * rejection of `run()` — reporting a failed program is the caller's job, not + * an exception path. + */ interface CodeRunResult { /** * The program's completion value (its top-level `return`), when it ran to @@ -53,6 +64,13 @@ interface CodeRunResult { 每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(Code Mode 消费方传入一个:`tools`)。参数与返回值必须可 structured-clone(运行时可能跨序列化边界桥接调用),且运行时将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): ```ts type-equiv +/** + * A named group of {@link CodeBindingFunction}s the runtime exposes to the + * program as one global object (e.g. `tools`). Function names are arbitrary + * strings — a runtime must treat names like `__proto__` or `constructor` as + * ordinary own properties (null-prototype construction), never as prototype + * collisions. + */ interface CodeBindingNamespace { /** The global identifier the program sees (must be a valid JS identifier). */ global: string @@ -62,6 +80,14 @@ interface CodeBindingNamespace { ``` ```ts type-equiv +/** + * One host-side function exposed to the program as an async callable. The + * runtime bridges calls to it (possibly across a serialization boundary), so + * `args` and the resolution value MUST be structured-cloneable; a runtime + * rejects a non-cloneable value with a descriptive error rather than + * corrupting the run. A rejection of this function surfaces inside the + * program as a rejection of the corresponding call. + */ type CodeBindingFunction = (args: unknown) => Promise<unknown> ``` @@ -72,6 +98,16 @@ type CodeBindingFunction = (args: unknown) => Promise<unknown> 失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个: ```ts type-equiv +/** + * Why a run failed. The kinds are orthogonal outcomes reported independently + * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * abort is not a timeout, and a substrate death is neither. + * + * - `'exception'` — the program threw or failed to parse/transform. + * - `'timeout'` — an implementation-owned budget expired; the message says which. + * - `'abort'` — {@link CodeRunRequest.signal} fired. + * - `'worker-exit'` — the execution substrate died without settling (e.g. OOM). + */ interface CodeRunFailure { /** The failure class (see the interface doc for each kind's meaning). */ kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index f1673efb53..99025fabab 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -compaction.md: e82cc103932ad05e68bc5311dec23c3f2c1a7ce4 -compaction.zh.md: bae6e9aa385efe0a7722c2b360c0b88e87a1a779 +compaction.md: 20513bdac5a10cb634c892f42600d2f016801e09 +compaction.zh.md: 67cd89add48761d269da4034bb7f3f3752e9a1ed diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index bae6e9aa38..67cd89add4 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -2,18 +2,18 @@ [English](compaction.md) | 中文 -上下文压缩(context compaction)的 seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md),按 bash 式拆分:接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(后端,如 [dsh-compact-basic](../../packages/compact/compact-basic))、消费方(一个 `/compact` 工具,暂缓实现)。上下文压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处,而非 [core.md](core.md)。基于 tokenizer 或模板的后端是实现同一接口的兄弟包。与 bash 不同的是,该接口必然依赖 `dsh-session` 和 `dsh-llm`:它的动词定义在 `Session` 之上,输出使用 `ContentBlock` 词汇(见[上下文压缩能力 seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md))。 +压缩 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md),与 bash 一样分为接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(例如 [dsh-compact-basic](../../packages/compact/compact-basic) 后端)和消费方(延期实现的 `/compact` 工具)。压缩是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此而非 [core.md](core.md) 中。基于 tokenizer 或模板的后端是实现同一接口的兄弟包。与 bash 不同,该接口必然依赖 `dsh-session` 和 `dsh-llm`:其动词作用于 agent 所有的 `Session`,而其持久摘要事件使用 `ContentBlock` 词汇(见[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md))。 源码:[`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) ## `compact/*` 会话事件 -上下文压缩通过声明合并为 [`SessionEventMap`](session.md) 扩展了三种事件类型。三者均为**仅日志**事件:它们记录压缩锁及其来源信息,永远不进入 surface。`SurfaceEventType` 被刻意不扩展(只有产生消息的事件才到达模型),因此摘要本身搭载在一条独立的 `user/message` 上,带有 `surfaceOp: { op: 'replace', start, end }`——唯一的 surface 变更。关于为何复用 `user/message` 是诚实的做法而非权宜之计,见 RFC。 +压缩通过声明合并为 [`SessionEventMap`](session.md) 扩展三种事件类型。三者都**仅写入日志**——记录压缩锁及其 provenance,绝不进入 surface。这里有意不扩展 `SurfaceEventType`(只有产生消息的事件才到达模型),因此摘要本身承载在另一条带有 `surfaceOp: { op: 'replace', start, end }` 的 `user/message` 上——这是摘要压缩执行的唯一 surface 变更。关于复用 `user/message` 为何是如实建模而非权宜之计,见对应 Agent Note。 | 事件 | 载荷 | 作用 | |---|---|---| | `compact/start` | `{ turn }` | 获取日志记录的锁 | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, model, maxTokens? }` | 来源信息:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq,是位置跨度而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算的 token 数量,以及摘要调用的信封(`model`,加上生效时的生成上限)。记录这些信息使得单次请求可从日志加代码重建(reconstructability RFC) | +| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance:摘要 block、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | | `compact/end` | `{ turn, error? }` | 释放锁(摘要调用抛出异常时设置 `error`) | 锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 @@ -22,9 +22,10 @@ ## `CompactionResult` -一次成功的压缩返回给调用方的内容:三个追加的 `compact/*` 事件的 seq、摘要块,以及被遮蔽的范围/seq 和估算的 token 数量。 +成功压缩向调用方返回:记账事件 seq、原始摘要、被遮蔽的范围与 seq,以及估算 token 数。 ```ts type-equiv +/** Result of a successful compaction operation. */ interface CompactionResult { /** The seq of the appended `compact/start` event. */ startSeq: number @@ -52,6 +53,45 @@ interface CompactionResult { ## 服务 -`CompactService` 暴露 `compactIfNeeded(...)` 用于压力触发的压缩(不需要压缩时返回 `null`),以及 `compactRegion(...)` 用于对显式的闭区间 surface 范围进行压缩。pre-step 调用方提供 agent、完整 prompt、会话前缀和 abort signal;实现必须将该 signal 转发给摘要生成。估算、保留策略、事件排序与摘要生成均为后端策略。 +自动调用方会说明策略为何运行;实现可以比普通压力更激进地处理已确认的溢出。 -自动压缩在串行的 `agent/pre-step` 时运行,位于步骤和请求推导之前,因此可以在替换 surface 节点的同时将 trace 事件保持在步骤之外。区域边界保持工具调用/结果配对,但不保持完整轮次,允许一个超大轮次中已关闭的早期步骤被压缩。保留策略与失败处理的细节由 `dsh-compact-basic` 负责。 +```ts type-equiv +/** Why automatic policy is asking a backend to consider compaction. */ +type CompactionTrigger = 'pressure' | 'context-overflow' +``` + +`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 + +压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲 context 和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败 step 关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的 step 重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个 turn,因此一个过大 turn 中较早关闭的 step 可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 + +该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与孤立结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 + +## 工具结果剪枝产出 + +可选的工具结果剪枝服务会报告每次持久内容替换以及 Unicode code point 的总减少量。其公开结果类型位于 [`compact-tool-result-prune/src/types.ts`](../../packages/compact/compact-tool-result-prune/src/types.ts)。 + +```ts type-equiv +/** Provenance and size accounting for one landed surface replacement. */ +interface PrunedEntry { + /** Full-fidelity tool-result event shadowed by the replacement. */ + readonly originalSeq: number + /** Newly appended pruned tool-result event. */ + readonly replacementSeq: number + /** Tool call shared by the original and replacement. */ + readonly callId: CallId + /** Original text size in Unicode code points. */ + readonly charsBefore: number + /** Replacement text size in Unicode code points. */ + readonly charsAfter: number +} +``` + +```ts type-equiv +/** Aggregate outcome of one stable-surface pruning pass. */ +interface PruneResult { + /** Replacements in the snapshotted surface order. */ + readonly pruned: readonly PrunedEntry[] + /** Total Unicode code points removed across replacements. */ + readonly charsRemoved: number +} +``` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index a1eaa5e393..c4c4fcf9d3 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -core.md: 7ace373ae549b86f21151610719940100d6348d0 -core.zh.md: 0212dee3587d8a67bcd484a67f078d7195f6eef7 +core.md: e39650c5cc6769cd5cebe7901f92130ea57920e7 +core.zh.md: 4ab9f849ecbc30603b5c6b2875f9e9457c073c46 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 0212dee358..4ab9f849ec 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -18,27 +18,31 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | 子页面 | 负责内容 | |---|---| | [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | +| [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | +| [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 round 归属 | +| [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | -| [session-query.md](session-query.md) | 逻辑会话/事件记录与有界精确事件读取 | +| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取与关系追踪 | +| [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、prompt 段落与协作式组装 | | [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、provider API、错误分类体系 | | [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | -| [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashTask` | -| [sandbox.md](sandbox.md) | 进程隔离 seam:文件效果模式、`SandboxPolicy`、`ConfinedArgv`、强制执行与 fail-closed 错误 | +| [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | +| [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 | | [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | | [filesystem.md](filesystem.md) | 文件系统 seam:`FsTarget`、读/写/编辑结果、观测到的文件状态、`FsErrorCode` | +| [lsp.md](lsp.md) | LSP 导航 seam:`LspQueryRequest`/`Result`、`LspProvider`/`Service`、四种操作、`LspError` | | [skills.md](skills.md) | skill 服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | | [compaction.md](compaction.md) | 压缩(compaction)seam:`compact/*` 会话事件、`CompactionResult`、`CompactService` 接口 | | [subagent.md](subagent.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | | [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、provider 可用性、`WebError` | +| [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | -> 本页的类型定义**逐字**粘贴自源码,并由 `pnpm run verify-type-equiv` 进行漂移检查(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。为可读性省略了行内 JSDoc;完整契约请跟随源码链接查看。 - -FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog. +> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通 block 保留完整声明;`public-api` block 保留去除实现体的公开 class 声明。Cordis 服务使用生成的[服务目录](../cordis-catalog/services.md)。 ## `…Map → derived-union` 模式 @@ -76,25 +80,30 @@ declare module '@deepseek-ai/dsh-llm' { ## 品牌化 ID -跨包边界的 ID 是**品牌化**的——结构上是字符串,但在类型层面不可互换(`AgentId` 不能传给期望 `CallId` 的地方)。构造通过每个类型专属的工厂函数;比较、日志和 JSON 行为与普通字符串一致。 +跨越包边界的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 -`Branded<B>` 原语位于自己的纯类型包 [dsh-brand](../../packages/util/brand)(无运行时代码,不依赖 harness 包),因此任何包都可以为自己拥有的 ID 品牌化,而无需依赖不相关的能力包(例如 dsh-bash 仅通过 dsh-brand 品牌化 `BashTaskId`/`OwnerToken`,从不引入 dsh-llm)。 +`Branded<B>` 原语位于独立的纯类型包 [dsh-brand](../../packages/util/brand) 中(没有运行时代码,也不依赖 Harness 包),因此任何包都能品牌化其拥有的 id,而无需依赖无关的能力包。 -Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) +源码:[`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) ```ts type-equiv +/** A string carrying a compile-time-only brand `B`. */ type Branded<B extends string> = string & { readonly [BRAND]: B } ``` -三个核心 ID:`CallId`(关联工具调用与其结果;dsh-llm)、`SessionId`(dsh-session)、`AgentId`(dsh-agent)。每个都是 `Branded<'CallId'>` 等加上同名工厂函数。能力 seam 也品牌化自己的 ID——见 [bash.md](bash.md) 中的 `BashTaskId`/`OwnerToken`。 +两个核心 ID 是 `CallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久 session 共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [tasks.md](tasks.md) 中的 `TaskId`。 ## 内容块与消息 一段对话由 `Message` 组成;一条消息是一个类型化**内容块**的数组。块的联合类型从 `ContentBlockMap` 派生。 -Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) +源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock @@ -105,18 +114,44 @@ interface ContentBlockMap { 各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`)、`ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持。 -`Message` 是角色加块: +`Message` 由角色和 block 组成。由循环派生的 assistant 消息携带其持久 provider/model 标识,以及可选的适配器私有回放元数据: ```ts type-equiv +/** Provider ownership and adapter-private replay data for an assistant message. */ +interface AssistantProvenance { + /** Provider route that produced the message. */ + provider: string + /** Provider model id that produced the message. */ + model: string + /** + * Lossless-JSON adapter state needed to replay the provider response. + * `LlmService` exposes it to a target adapter only when that adapter instance + * currently owns both this historical provider and the target provider. + */ + replayState?: unknown +} +``` + +```ts type-equiv +/** + * A single message in a conversation history. Loop-derived assistant messages + * always carry provenance; callers may omit it on hand-built foreign history. + */ interface Message { role: 'system' | 'user' | 'assistant' content: ContentBlock[] + /** Present only on assistant messages produced by a routed adapter. */ + provenance?: AssistantProvenance } ``` 消息来源本身也是一个可合并扩展的和类型: ```ts type-equiv +/** + * Where a message (or injected content) came from. + * Merge-extensible sum type — plugins add their own `kind`s. + */ interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } @@ -133,10 +168,49 @@ interface MessageSourceMap { 一次模型调用是一个完全组装好的 `GenerateOptions`。适配器以原始 `StreamChunk` 流作答;消费方用 `BlockAssembler` 组装它(见 [llm-streaming.md](llm-streaming.md))。 -Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) +源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) + +提供方与模型发现使用小型、提供方中立的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 ```ts type-equiv +/** Display metadata for one registered provider route. */ +interface LlmProviderInfo { + /** Provider route key used by {@link GenerateOptions.provider}. */ + id: string + /** Human-readable provider name for selectors and diagnostics. */ + name: string +} +``` + +```ts type-equiv +/** One adapter-discovered model; catalog membership is advisory, not request validation. */ +interface LlmModelInfo { + /** Provider route that owns this model entry. */ + provider: string + /** Model id passed to {@link GenerateOptions.model}. */ + id: string + /** Human-readable model name for selectors. */ + name: string + /** Optional user-facing distinction from otherwise similar models. */ + description?: string +} +``` + +对正确性敏感的模型容量与参考目录分开查询,并归服务该确切路由的适配器所有。 + +```ts type-equiv +/** Provider-owned context capacity for one exact provider/model route. */ +interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} +``` + +```ts type-equiv +/** A single model request, fully assembled. */ interface GenerateOptions { + /** Registered provider route selecting the adapter instance. */ + provider: string model: string /** * Ordered conversation messages, exactly as the provider sees them (after @@ -163,18 +237,28 @@ interface GenerateOptions { * it; replay uses it to keep concurrent parent and child cursors independent. */ sessionId?: Branded<'SessionId'> + /** + * Provider-neutral classification for an auxiliary model call. Adapters may + * map the purpose to model-hidden transport metadata. Ordinary conversation + * requests leave it unset. + */ + purpose?: 'compaction' } ``` -模型停止生成的原因是一个可合并扩展的结束原因: +模型响应为何停止由可合并扩展的原因表示。提供方终态失败携带流式契约的 [`LlmFailure`](llm-streaming.md#llmfailure): ```ts type-equiv +/** + * Why a model response stopped. + * Merge-extensible so adapters can surface provider-specific reasons. + */ interface FinishReasonMap { 'stop': { kind: 'stop' } 'tool-calls': { kind: 'tool-calls' } 'max-tokens': { kind: 'max-tokens' } - 'aborted': { kind: 'aborted' } - 'error': { kind: 'error'; message: string; code?: string } + 'aborted': { kind: 'aborted'; failure: LlmFailure } + 'error': { kind: 'error'; failure: LlmFailure } } ``` @@ -183,6 +267,13 @@ interface FinishReasonMap { `GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环每一步组装请求的一部分: ```ts type-equiv +/** + * JSON-schema description of a tool, as sent to the model. + * + * Declared here (not in dsh-tools) because it is part of {@link GenerateOptions}; + * dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import + * it from this package. + */ interface ToolSchema { name: string description: string @@ -195,16 +286,22 @@ interface ToolSchema { ### 请求信封:`LlmCallConfig` 与记录的 header -循环从已记录的状态构建每个请求。`EpochHeader` 记录调用配置、渲染后的 prompt、权威的返回工具顺序(由 `toolOrder` 配置,未设置时按字典序)以及会话前缀,通过 `request/header` 快照和 delta 实现。结合派生历史,这使得请求可从会话日志重建。见 [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta) 和[可重建请求 RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的 prompt、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及 session prefix。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收一个冻结的 call-config 种子,可以返回替换值。`agent/session-prefix` 在每个循环实例中组合一次仅用于请求的前缀消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求已被深度冻结,因此突变会抛出异常。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的 prompt 组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. +FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 ```ts type-equiv +/** + * Provider + model + sampling scalars of one conversation's requests. Every field maps + * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests + * from the logged header rather than accepting these per call. + */ interface LlmCallConfig { + provider: string model: string temperature?: number maxTokens?: number @@ -216,9 +313,22 @@ interface LlmCallConfig { `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。事件词汇从 `SessionEventMap` 派生: -Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) +源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ type SessionEvent<T extends SessionEventType = SessionEventType> = { [K in SessionEventType]: { type: K @@ -231,7 +341,9 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction replace node). + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ @@ -240,157 +352,166 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -十五个事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`、`request/header-delta`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因,以及轮次封闭不变式在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复和 `SessionHeader`——在 **[persistence.md](persistence.md)** 中。 +十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及 turn enclosure 不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` checkpoint、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## Agent 句柄 -`Agent` 是每个插件(UI、钩子、编排器)面向编程的接口。具体实现是 dsh-agent-loop 中的 `ReactLoopAgent`;循环之外没有任何东西依赖该实现。 +`Agent` 是每个插件(UI、hook、orchestrator)面向编程的 surface。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。 -Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) + +`InjectOptions` 在普通消息归属信息之外,扩展了对模型隐藏的持久 JSON 元数据: ```ts type-equiv +/** Options specific to durable synthetic context injection. */ +interface InjectOptions extends SendOptions { + /** Opaque JSON state retained in the session event but hidden from the model. */ + meta?: JsonValue +} +``` + +```ts type-equiv +/** Stable runtime cause accepted by {@link Agent.cancel}. */ +type AgentCancelCause = + | { readonly kind: 'user' } + | { readonly kind: 'parent' } +``` + +```ts type-equiv +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ interface Agent { - readonly id: AgentId + /** The single identity shared with {@link session}. */ + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus - - /** - * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent): - * registrations through it — tools, prompt sections/variables, listeners, - * restrictions — are visible to this agent only and unwind when it is - * disposed; `agent.ctx.on('agent/…')` listeners fire only for this agent. - */ + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, - * deeply-frozen lossless-JSON record before notification or enqueue, so - * caller or `agent/queued` listener in-place mutation cannot change later - * log/model input. Throws synchronously when either value is not losslessly - * JSON-serializable; `agent/prompt-submit` may still return an explicit - * replacement. + * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole + * ordinary message in its FIFO-ordered turn; the next claimed item waits for + * that turn's checkpoint. + * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void /** - * Steer a running turn: content is injected between steps of the current - * turn. Uses the same owned-value and synchronous-validation boundary as - * {@link send}; when idle, behaves exactly like that method. + * Submit steering while the agent is `running`. An open turn records it at + * the next steering checkpoint before a request or continuation decision; + * policy may stop before another step. After turn close and its checkpoint, + * any remainder is queued for a later turn; terminal `agent/turn-stop`, + * cancellation, or disposal may discard it. Uses the same synchronous + * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. */ steer(content: ContentBlock[], options?: SendOptions): void /** - * Inject in-session context (file-change notices, skill content, cron - * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as tagged synthetic - * context rather than a user prompt. Does not run the model. - * - * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; - * an inject while idle wraps its `context/message` in a one-shot `injection` - * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for - * durability, so every event stays inside a turn and a persistence backend - * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * (inject is synchronous): a failing flush is reported via `agent/error` - * (step `0`) and the logger, never thrown into the caller. - * - * Live-adapter review has validated the tagged-envelope rendering against - * current DeepSeek behavior; provider-specific mismatches belong in that - * adapter, not in the canonical session vocabulary. + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before turn + * close even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. */ - inject(content: ContentBlock[], options?: SendOptions): void + inject(content: ContentBlock[], options?: InjectOptions): void /** - * Cancel ALL pending work for the agent. `cancel()`: - * - * - clears the queued FIFO (un-started prompts never run) and the steering - * FIFO (steering for the cancelled turn is dropped, not re-enqueued); - * - aborts the in-flight step if one is running (the turn ends `aborted`); - * - drops a turn that is about to start (a `cancel()` landing in the - * pre-step window — after a `send()` queued but before the loop flips to - * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. - * - * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. - * `cancel()` on an idle agent with nothing queued or running is a safe no-op - * — it does NOT arm anything that would drop a later legitimate prompt. + * Clear all queued and steering work, including items waiting to start, and + * abort the active turn. An effective call first emits + * `agent/cancel-requested` with the resolved typed cause. The first cause wins + * for the active turn, and `whenIdle()` resolves after cancellation reaches + * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op + * and does not arm later work. The active turn snapshots and freezes the cause. + * @param cause - the stable caller intent carried by the current turn signal. */ - cancel(reason?: string): void + cancel(cause?: AgentCancelCause): void - /** - * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. A - * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle awaits this to proceed only after queued/running work has - * fully stopped, rather than returning while the driver is still streaming or - * about to start a queued turn — without itself tearing the agent down. (A - * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the - * loop-exit promise directly as part of stopping and unregistering. So this is - * for a non-owning observer — e.g. a test awaiting a turn to settle, or a - * monitor — that wants the settle signal but must not dispose the agent.) - * - * "Quiescence", not merely "status changed": a disposed agent emits - * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop - * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop - * to actually exit (the implementation chains the loop-exit promise), not just - * observe the status flip. A mid-step disposal that never reaches `idle` still - * unblocks the await this way. - */ + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise<void> - // Subagent delegation is realized on top of this interface by the - // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates - // the child through `ctx.agents.create` (fork seeds the child Session with a - // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn - // starts fresh) and drives it as an ordinary Agent handle, so steer() and - // event subscription work uniformly. See docs/core-data-structures/subagent.md. } ``` -`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`AgentId` 是品牌化的。`AgentOptions` 可合并扩展,当前包含 `model?`。Persona 属于 `dsh-system-prompt`:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越 turn 关闭、其持久化 checkpoint 以及连续的排队 turn;它不能证明某个 turn 仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 -[事件分类体系](../architecture.md#event)拥有 `agent/*` 生命周期、检查点和 waterfall(瀑布式事件)契约。轮次和步骤边界是持久的会话事件,而非 agent 发射。 +cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`;该值在发布 `turn/end` 前退役。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 + +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、checkpoint 与 waterfall 契约。Turn 和 step 边界是持久 session 事件,而不是 agent emit。 + +## 发起 Agent + +`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;其生命周期与边界规则由 [initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)规定。 ## 拦截决策 -每个 `agent/*` 拦截 waterfall 返回一个小型的、seam 特定的类型化联合——统一的 Decision 惯用法(工具 seam 的 `PreToolDecision`/`PostToolDecision` 在 [tools.md](tools.md) 中遵循相同形状)。CC/Codex 钩子桥将其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些类型上;原生插件直接返回它们。它们共享一个面向模型的上下文信封 `HookContext`,通过 `inject()` 作为 `context/message` 注入,因此携带一个必需的 `source`(缺少 source 会默认为 `{kind:'user'}`,将插件上下文错误标记为用户提示词)。 +每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex hook bridge 把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。Prompt 与工具后决策共享一种面向模型的 context 形状 `HookContext`;它通过 `inject()` 作为 `context/message` 注入,因此必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件 context 错标为用户 prompt)。其中的 `content` 作为 user-role 消息逐字到达模型,而 JSON `meta` 持久保存插件状态但不向模型暴露。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance 与元数据。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 -Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv +/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */ interface HookContext { content: ContentBlock[] source: MessageSource + /** Opaque JSON state retained in the session event but hidden from the model. */ + meta?: JsonValue } ``` -`agent/prompt-submit` 返回 `PromptDecision`(允许一条已出队的排队消息——可选地重写其 `content` 或附加 `additionalContext`——或阻止它;一个批次中所有 prompt 都被阻止时,会打开一个零步骤轮次并以 `rejected` 结束): +`agent/prompt-submit` 返回 `PromptDecision`(允许 turn 已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零 step turn): ```ts type-equiv +/** + * Prompt interception result. `allow.content` replaces the prompt and each + * `additionalContexts` entry becomes a separate context message. `block` + * records a durable `prompt/blocked` and ends the claimed prompt's zero-step + * turn as rejected. + */ type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` 返回 `ContinuationDecision`(循环的默认行为是:当步骤有工具调用或 steering(中途引导)被注入时 `continue`,否则 `stop`;`continue` 的 `reason` 被记录为同一轮次中下一步的 steering——类型化的 `/goal` 模式): +`agent/turn-continuation` 返回 `ContinuationDecision`(step 有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一 turn 中下一 step 的 steering,因此不携带 context 元数据——即类型化 `/goal` 模式): ```ts type-equiv +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ type ContinuationDecision = | { action: 'stop' } - | { action: 'continue'; reason?: HookContext } + | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` +`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、turn signal 以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: + +```ts type-equiv +/** Model-request failure with an optional machine-routable provider code. */ +type RequestError = Error & { code?: string } +``` + +它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的 step,而 `fail` 在 `turn/end` 上保留结构化失败: + +```ts type-equiv +/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ +type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } +``` + +`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲 context 与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在 session 日志中,而不是瞬态 payload 中。 + `agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。 ```ts type-equiv +/** + * The terminal subset of {@link ContinuationDecision}. A listener on + * `agent/turn-stop` returns this to make the already-composed continuation + * outcome terminal; `undefined` abstains. + */ type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }> ``` `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): ```ts type-equiv +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 843abd6dc6..69cb43d21d 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -filesystem.md: 8bdc2323a0bf63588e01520926f093538fee4912 -filesystem.zh.md: 86b18af783899ed857f059b6b7cdb740ea357798 +filesystem.md: 3c9b041da92e71cd20429e8b1549c0b8e6f2436d +filesystem.zh.md: 11e386c98e671f4147419eec587399a5a3bb0d07 diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 86b18af783..11e386c98e 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -13,8 +13,17 @@ 每个操作首先将用户提供的路径解析为不透明的后端目标。消费方可以显示 `displayPath`,但禁止解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。 ```ts type-equiv +/** + * A path resolved by a backend into a stable identity. `resolve()` produces + * this; every other operation takes it. + */ interface FsTarget { + /** Opaque key for stale guards and target lookup. */ targetKey: FsTargetKey + /** + * Path for model/UI-facing output. May be a local absolute path, + * workspace-relative path, or remote URI depending on the backend. + */ displayPath: string } ``` @@ -22,19 +31,59 @@ interface FsTarget { 后端拥有文件版本 token,即 write/edit 所守卫的新鲜度 token。策略插件存储它们以进行陈旧检查;消费方不解释其内容。两个 id 都是品牌化的不透明字符串。 ```ts type-equiv +/** + * Opaque key for stale guards and target lookup. The local backend uses a + * realpath-like string; a remote backend might use a workspace URI or file id. + * Consumers MUST NOT parse it or assume it is a local absolute path. + */ type FsTargetKey = Branded<'FsTargetKey'> ``` ```ts type-equiv +/** + * Opaque file-version token — the freshness token a write/edit guards against. + * The local backend derives it from high-resolution stat identity and freshness + * fields; a remote backend might use a revision id. The policy layer records it + * for stale checks; consumers may display related metadata but MUST NOT + * interpret this token. + */ type FsVersion = Branded<'FsVersion'> ``` `stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录或特殊文件;`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。 ```ts type-equiv +/** + * Metadata about a target — what {@link FileSystem.stat} returns. Lets the + * policy layer reject directories/special files before reading and choose + * `readText` vs `streamText` from `size` without probing by failure. `version` + * is the freshness token. `undefined` from `stat` means the target is absent. + */ interface FsInfo { + /** Opaque freshness token of the target right now. */ version: FsVersion + /** Whether the target is a regular file, a directory, or something else. */ type: 'file' | 'directory' | 'other' + /** Byte size of a regular file, when the backend can report it. */ + size?: number +} +``` + +`lstat` 是路径层级、不跟随链接的元数据原语。它接收路径而不是 `FsTarget`,因为 `resolve` 会有意跟随 symlink 以产生稳定标识;需要检查信任边界的消费方可以先调用 `lstat`,在解析前拒绝 `symlink`。 + +```ts type-equiv +/** + * Metadata about a path without following the final path component when it is a + * symbolic link. Unlike {@link FsInfo}, this path-level probe can report + * `symlink` so consumers with trust-boundary rules can reject repository-owned + * links before resolving a target. + */ +interface FsPathInfo { + /** Opaque freshness token of the path entry right now. */ + version: FsVersion + /** Whether the path entry is a regular file, directory, symlink, or other. */ + type: 'file' | 'directory' | 'symlink' | 'other' + /** Byte size of the path entry, when the backend can report it. */ size?: number } ``` @@ -42,11 +91,20 @@ interface FsInfo { `listDir` 按稳定的名称顺序返回直接子条目。每个条目携带子项的 basename、类型、已解析目标,以及后端能报告时的廉价元数据。它禁止读取文件内容,因此 `size` 仅用于普通文件,`version` 来自元数据。已损坏或已消失的子项可以作为 `other` 返回且不带元数据;列出或解析子项元数据时的权限或后端 I/O 失败会以 `FS_PERMISSION_DENIED` 或 `FS_IO_ERROR` 使整个列表操作失败。 ```ts type-equiv +/** + * One direct child returned by {@link FileSystem.listDir}. Listing returns + * metadata and resolved targets only; it must not read file contents. + */ interface FsDirEntry { + /** Basename of the child inside the listed directory. */ name: string + /** Whether the child is a regular file, a directory, or something else. */ type: 'file' | 'directory' | 'other' + /** Resolved child target for follow-up operations. */ target: FsTarget + /** Opaque freshness token when the backend can report metadata cheaply. */ version?: FsVersion + /** Byte size of a regular file, when the backend can report it. */ size?: number } ``` @@ -56,16 +114,33 @@ interface FsDirEntry { `writeText` 和 `editText` 的版本守卫都是可选的:省略它执行无条件(裸提供方)变更,提供它则启用守卫。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 ```ts type-equiv +/** + * Guarded write intent. `createIfAbsent` rejects an existing target with + * `FS_NOT_OBSERVED`; `replaceIfVersion` rejects absence or mismatch with + * `FS_STALE_VERSION`. Omitting the intent from `writeText` means unconditional + * create-or-overwrite, not a third union arm. + */ type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` ```ts type-equiv +/** Outcome of a full-file write. */ interface FsWriteOutcome { + /** Whether the write created a new file or replaced an existing one. */ operation: 'create' | 'update' + /** Opaque version of the file after the write. */ version: FsVersion + /** + * The file's content BEFORE the write, or `null` when the file did not exist + * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text + * (the diff basis), never a diff — a consumer computes the result-time + * contextual diff from `before`/`after` when `before` is present, else falls + * back to a whole-file diff. + */ before: string | null + /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ after: string } ``` @@ -73,17 +148,29 @@ interface FsWriteOutcome { `editText` 是提供方级别的变更操作,而非在别处组合的 `read` 加 `write`。带守卫时,它在字面匹配之前先验证预期版本(因此对陈旧内容的编辑报 `FS_STALE_VERSION`,而非对更新内容的匹配失败);不带守卫时,它编辑当前内容。无论哪种路径,它都应用替换并原子写入——将匹配、行尾处理、陈旧检查和原子替换保持在一个变更临界区内——目标缺失时两条路径都报 `FS_STALE_VERSION`。 ```ts type-equiv +/** A literal-replacement edit request. */ interface FsEditRequest { + /** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */ oldString: string + /** Literal replacement text. An empty string deletes the matched text. */ newString: string + /** Replace every match instead of requiring exactly one. */ replaceAll: boolean } ``` ```ts type-equiv +/** Outcome of a literal edit. */ interface FsEditOutcome { + /** Opaque version of the file after the edit. */ version: FsVersion + /** + * The file's content BEFORE the edit. Raw storage text (LF-normalized by the + * backend), never a diff — a consumer computes the result-time contextual diff + * (the applied hunk with context) from `before`/`after`. + */ before: string + /** The file's content AFTER the edit. */ after: string } ``` @@ -99,8 +186,20 @@ interface FsEditOutcome { 策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入 tool、agent 或 session 包。 ```ts type-equiv +/** + * Minimal structural view of a tool execution the policy plugin needs to derive + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies + * this shape, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to this + * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * + * The owner is `agent.session` when present. It is treated as an opaque object + * identity (a `WeakMap` key); this package never reads any of its fields. + */ interface FsPolicyExec { + /** The agent on whose behalf the call runs, when there is one. */ agent?: { + /** The session that owns observed-file state, used as an opaque key. */ session?: object } } @@ -111,10 +210,15 @@ interface FsPolicyExec { 文本读取受行窗口、字节上限和后端限制约束。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接用 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 ```ts type-equiv +/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ interface FileReadOutcome { + /** 1-based first line requested. */ offset: number + /** Returned lines, already numbered. */ lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ truncatedByBytes?: true } ``` @@ -128,12 +232,18 @@ interface FileReadOutcome { 文件系统故障使用稳定的 `FsErrorCode` 字符串,由 `FsError`(`HarnessError`)携带。工具注册表在错误结果上保留 `{ name, code }`,使重试、权限和 UI 层可以按 code 分支而无需解析文本。 ```ts type-equiv +/** + * Stable, machine-routable codes for filesystem failures. Carried on + * {@link FsError}; the tool registry surfaces `{ name, code }` on `isError` + * results so retry/permission/UI layers can branch without parsing messages. + */ type FsErrorCode = | 'FS_NOT_FOUND' | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' | 'FS_PERMISSION_DENIED' + | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' @@ -142,8 +252,8 @@ type FsErrorCode = | 'FS_ABORTED' ``` -`FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 和 `FS_IO_ERROR` 用于目录列表操作,分别区分目标存在但不是目录、列表被拒绝、以及意外的后端 I/O 故障。`FS_NOT_OBSERVED` 表示策略插件对该所有者没有先前观测记录(或 `createIfAbsent` 遇到了已存在的文件)。`FS_STALE_VERSION` 表示后端版本不再匹配已观测版本(或 edit 遇到了缺失的目标)。新鲜度授权没有 partial/full 区分,因此不存在 `FS_PARTIAL_OBSERVATION`。 +目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行 sandbox 的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观察记录(或 `createIfAbsent` 遇到了现有文件)。`FS_STALE_VERSION` 表示后端版本不再与观察到的版本匹配(或编辑操作遇到缺失目标)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 ## 服务与插件 -`FileSystem`(`ctx.fs`,抽象)拥有提供方原语:`resolve`、`stat`、`readText`、`streamText`、`listDir`、`writeText` 和 `editText`。`dsh-fs-policy` **不注册任何服务**——它是一个通过 `fs/*` 事件门控叠加策略的插件:它裁决 write/edit intent waterfall(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。生成的接线目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 中展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:对写入/编辑意图 waterfall 作出决策(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }`,或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。生成的 wiring 目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 中展示确切的 `ctx.fs` 签名。 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index b06be9dfa6..22691c44f4 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -llm-streaming.md: ffd276b4647be8d10afcab0fb3c3f6daad790d20 -llm-streaming.zh.md: ea11f47e1148d21c2f23a648002c9413103628f1 +llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb +llm-streaming.zh.md: 5dc270acc97cdeac8cf24a36c443d550e14122dc diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index ea11f47e11..5dc270acc9 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -11,6 +11,13 @@ 一个流式响应交错包含多种类型的块(文本、推理(reasoning)、多个工具调用)。`index` 将每个 delta 关联到其所属块;`block-end` 携带完整组装好的 `ContentBlock`,消费方无需自行重新组装 delta。这是一个**封闭的**可辨识联合类型:对 `type` 的 `switch` 以 `assertNever` 结尾,因此新增变体会在每个必须处理它的消费方处触发编译错误。 ```ts type-equiv +/** + * Raw streaming protocol emitted by adapters. + * Block indexes correlate interleaved deltas, and `block-end` carries the + * assembled block. Adapters emit usage before the terminal finish and nothing + * afterward; tool arguments remain raw JSON strings. Failures either throw or + * end with `error`/`aborted`, and consumers must handle both paths. + */ type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'text-delta'; index: number; text: string } @@ -18,7 +25,32 @@ type StreamChunk = | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string } | { type: 'block-end'; index: number; block: ContentBlock } | { type: 'usage'; usage: TokenUsage } - | { type: 'finish'; reason: FinishReason } + | { + type: 'finish' + reason: FinishReason + /** Adapter-private lossless-JSON state for replaying a successful response. */ + replayState?: unknown + } +``` + +## `LlmFailure` + +每个抛出的失败或 final-adapter 带内失败都会规范化为一种可序列化、提供方中立的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 + +```ts type-equiv +/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +interface LlmFailure { + /** Human-readable provider or transport failure. */ + readonly message: string + /** Stable provider-neutral machine-routing code. */ + readonly code: string + /** HTTP status observed at the provider boundary, when available. */ + readonly status?: number + /** Provider-requested delay in milliseconds, when valid and available. */ + readonly providerRetryAfterMs?: number + /** Opaque provider-issued request identifier for diagnostics. */ + readonly requestId?: ProviderRequestId +} ``` ## 适配器契约 @@ -27,28 +59,50 @@ type StreamChunk = - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条许可的错误路径。** 失败可以从 `stream()` 中 THROW(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted'}` 结束流(提供方带内错误,适用于无法在流中途抛出异常的适配器)。消费方必须同时处理*两种*情况。agent loop(智能体循环)将 finish-error/aborted 转化为轮次错误,绝不会为失败的步骤记录一条正常完成的 assistant 消息。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。final adapter 边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的 step,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为 turn 错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的 step;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 +- **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 +- **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 +- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方中立的内容与 provenance,不会收到私有状态。 -这份契约正是两个适配器作为刻意配对存在的原因:`dsh-llm-deepseek`(手写 fetch/SSE(Server-Sent Events))与 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 访问同一端点)。两套独立内部实现共享一份契约,正是将协议固定下来的方式:基于库的适配器无法在流中途抛出异常,因此它走通了手写适配器可能不会走到的 finish-chunk 错误路径。 +该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish-chunk 错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 ## `AppIdentity`:应用归属 -每个适配器向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 仅将其映射为标准 `User-Agent` header;本契约有意不支持 OpenRouter 特有的应用归属 header。默认的 `APP_IDENTITY` 从 package manifest(元数据清单)获取版本号;每个字段都是公开的产品事实,不含密钥、路径、会话 id 或用户级标识符,且任何请求级信息都不得影响这些值。设计依据见 [Mandatory `User-Agent` attribution](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包 manifest 获取版本;每个字段都是公开产品事实——不含 secret、路径、session id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 ```ts type-equiv +/** + * Static public application identity sent to LLM providers. + * + * Every field is a public product fact, safe on every request: no secrets, + * local paths, session ids, prompt text, or per-user identifiers belong here, + * and nothing per-request may influence the values. + */ interface AppIdentity { + /** `User-Agent` product token (lowercase, hyphenated). */ product: string + /** Product version; sourced from package metadata, never hand-copied. */ version: string + /** Public home URL of the app, used as the `User-Agent` comment. */ url: string } ``` ## `TokenUsage` -单次调用的 token 用量统计。各计数**互不重叠**:`inputTokens` 仅为未缓存的输入;缓存命中的输入单独报告,计费输入是三者之和。如果提供方将缓存命中合并到单一的 prompt 总量中(如 DeepSeek 的 `prompt_tokens`),适配器需将其减回去。 +逐调用 token 记账。各计数**互不重叠**:`inputTokens` 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一 prompt 总数(如 DeepSeek 的 `prompt_tokens`),适配器会再将其扣除。`reasoningTokens` 存在时只是信息性细节,已经包含在 `outputTokens` 中;汇总时不得重复相加。 ```ts type-equiv +/** + * Token accounting for one model call (cache fields are optional). + * + * Counts are DISJOINT: `inputTokens` is uncached input only; cached input is + * reported separately as `cacheReadTokens`/`cacheWriteTokens` (billed input = + * sum of the three). Adapters whose providers fold cache hits into a total + * prompt count (DeepSeek's `prompt_tokens`) subtract them out. + */ interface TokenUsage { inputTokens: number outputTokens: number @@ -60,15 +114,99 @@ interface TokenUsage { ## `BlockAssembler` -`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,将 `StreamChunk` 流折叠回 `ContentBlock` 列表与最终的 `Message`。agent loop 记录原始分片(保证回放保真度),同时将相同的分片送入 assembler,因此权威日志保留了 token 级细节,而派生消息可确定性地重建。需要组装结果而不想重新实现折叠逻辑的消费方使用它。 +`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,负责把 `StreamChunk` 流折叠回 `ContentBlock`、usage、finish reason 与 replay state。循环在记录原始 chunk 的同时,把同一批 chunk 送入 assembler,再将组装后的 assistant 内容连同其 provider/model provenance 一起存储。需要组装结果、又不想重新实现 fold 的消费方使用它。 + +```ts public-api +/** + * Incrementally assembles raw {@link StreamChunk}s into complete + * {@link ContentBlock}s and a final assistant {@link Message}. + * + * The agent loop feeds it while logging raw chunks for replay fidelity, then + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * + * Tolerant of delta-only protocols (no block-start/end); deltas arriving for + * an index already closed by `block-end` are ignored (malformed stream) so a + * misbehaving adapter cannot grow memory or corrupt a completed block. + */ +declare class BlockAssembler { + /** + * Feed one chunk into the assembly state. + * @param chunk - the next raw chunk, in stream order. + */ + push(chunk: StreamChunk): void; + /** + * Assemble all blocks seen so far, in stream order. + * @returns one block per seen index; an open block assembles from its + * accumulated deltas (an unknown block type never closed by `block-end` throws). + */ + blocks(): ContentBlock[]; + /** Usage from the `usage` chunk; undefined until one arrives. */ + get usage(): TokenUsage | undefined; + /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ + get finish(): FinishReason; + /** Adapter-private replay state from the terminal finish chunk, if any. */ + get replayState(): unknown; + /** + * The assembled assistant message. + * @returns an assistant-role message over `blocks()` (same open-block assembly rules). + */ + message(): Message; +} +``` ## seam -`LlmAdapter` 是提供方 seam:继承它、实现 `stream()`、通过 `ctx.llm.registerAdapter(models, adapter)` 注册。`block-start`/`block-end` 的 `index` 关联加上 assembler 意味着适配器只需发出格式正确的分片,块重组不是各适配器需要操心的事。消费方接口(`ctx.llm.stream()`)与 `llm/stream` waterfall(瀑布式事件)在 [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm) 中描述。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的 chunk——block 重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容 block 与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 + +```ts public-api +/** + * Provider-wire adapter for the harness message and stream vocabulary. Register implementations + * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled + * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + */ +declare abstract class LlmAdapter { + /** + * Describe one provider route owned by this adapter. + * @param provider - a route passed to `registerAdapter()` for this instance. + * @returns detached display metadata whose id must equal `provider`. + */ + providerInfo(provider: string): LlmProviderInfo; + /** + * List models this adapter can currently advertise for one owned provider. + * The result is advisory: an adapter may accept unlisted model ids, and + * consumers must not turn absence into request rejection. + * @param _provider - one provider route owned by this adapter. + * @returns discoverable models in adapter-preferred order. + */ + listModels(_provider: string): Promise<readonly LlmModelInfo[]>; + /** + * Resolve context capacity for one model accepted by this adapter. Absence + * means the adapter does not know the capacity, not that routing is invalid. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns provider-owned context metadata, or `undefined` when unavailable. + */ + resolveModelContext( + _provider: string, + _model: string, + ): Promise<LlmModelContext | undefined>; + /** + * Stream one model call as raw chunks. The only required method. + * @param options - the fully-assembled request; implementations must honor `options.signal`. + * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`. + */ + abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>; +} +``` `ContentBlockType`(`index` 关联块所携带的键集合)派生自 `ContentBlockMap`: ```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 3072063a43..d8baef895b 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -persistence.md: ce7a21a5613da903a9bddd339e122bf9f899d2bd -persistence.zh.md: 33ddda66aacc21f847dfd45702b11b5381711cce +persistence.md: d4bb4ed1b65eb74ff03483bf3a71a1736300834e +persistence.zh.md: 93a418b3b06dbf0d42978900ef79a0fb86ac15f7 diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 33ddda66aa..93a418b3b0 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -4,16 +4,34 @@ 事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐项列举。 -该 seam 是典型的[能力 seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在已有的 `SessionEvent` 之上定义 create/append/load/list 操作,**没有并行的持久化类型**,以及两个可互换的后端,它们通过同一套 `runPersistenceContract` 测试。详见 [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md)。 +该 seam 是典型的[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append/load/list——**没有平行的持久化类型**——以及两个可互换、通过同一套 `runPersistenceContract` 的后端。见 [session-persistence Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件对其进行缓冲(write-behind),并在 agent loop(智能体循环)于每个轮次结束时触发的 `session/flush` 检查点处排空缓冲区。flush 使用 `ctx.parallel`(被 await):一个轮次的事件在下一个轮次开始前已被持久提交,轮次边界即提交边界。flush 拒绝时通过 `agent/error` 和 logger 报告,而非作为会话事件(那样会落在提交边界之后),因此后端保留其缓冲事件等待下一次 flush。 +`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通 turn 的 checkpoint 后再领取下一个队列项;同步的 idle `inject()` 会调度自己的 checkpoint 而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭 turn 作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭 turn 之后的 session 事件——而后端会保留已缓冲事件供下次 flush 使用。 ## 崩溃恢复保留被中断的轮次 后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次闭合不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 +## `SessionLocation`——可选的逐会话制品目标 + +`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各 session 共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前未 flush turn 的文件;它是位置提示,不是授权或新鲜度保证。 + +```ts type-equiv +/** + * A backend-resolved, per-session local artifact location. The path is an + * absolute target path and can name an artifact that has not materialized yet. + * Consumers must treat it as a location hint, never as an authorization token. + */ +interface SessionLocation { + /** Backend-specific artifact kind, for example `jsonl`. */ + readonly kind: string + /** Absolute path to this session's backend-owned artifact. */ + readonly path: string +} +``` + ## `SessionHeader`:日志旁的元数据 每个会话的元数据与事件日志**分开**存储:格式版本、cwd、血统与 seed 边界是存储层关注点而非对话事件,因此不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。 @@ -21,6 +39,9 @@ 源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv +/** + * Immutable validated storage metadata, kept outside the conversation event log. + */ interface SessionHeader { /** * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the @@ -37,43 +58,42 @@ interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were INHERITED via a seed rather than produced by - * this session — the seed boundary. Set when a fork seeds a child with a - * prefix of the parent's log (= the seeded prefix length); absent/0 means the - * session produced all its own events. Persisted so a reload reconstructs the - * boundary instead of re-deriving it from the full stored log, and so a replay - * harness can skip the inherited prefix when deriving the child's OWN script - * (the seeded events are the parent's, not this child's model calls). + * How many leading events were inherited through a seed. Persisting this + * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number + /** + * Delegation depth: absent (zero) for a top-level session, parent depth + 1 + * for a subagent child. Persisted so a recursion budget survives restart and + * resume — a runtime-only depth would reset a resumed child to top-level. + */ + readonly delegationDepth?: number } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时接受 `seed`(回放/fork 已有事件日志)和 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并默认 `createdAt`;调用方提供经过校验的绝对路径 `cwd`、`parentSession` 血统、`seedLength` seed 边界,以及仅在重建持久化会话时提供的原始 `createdAt` 以保留其值。 +通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化 session 时——需要保留的原始 `createdAt`。 ```ts type-equiv +/** + * Options for creating a {@link Session} via the store. `seed` replays/forks + * an existing event log; `meta` carries the caller-supplied storage fields the + * store folds into a {@link SessionHeader}. + */ interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ readonly seed?: readonly SessionEvent[] /** - * Creation metadata. The store fills in `version`/`id` and defaults - * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and - * — when reconstructing a persisted session — the original `createdAt` to - * preserve it). - * - * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction - * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full - * length, not the original boundary — the caller must pass the persisted - * boundary back. A fresh fork passes its actual seeded-prefix length. + * Storage metadata read once before publication. `seedLength` is explicit + * because a resumed seed contains the full stored log, not only its inherited prefix. */ readonly meta?: { readonly cwd?: string readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number + readonly delegationDepth?: number } } ``` @@ -82,9 +102,9 @@ interface CreateSessionOptions { ## 后端 -两者实现相同的抽象 `SessionPersistence`(在 `SessionEvent` 之上提供 create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 真正与后端无关: +两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**:每个会话一个仅追加的 JSONL 日志,具备崩溃安全的原子写入、上述中断轮次崩溃恢复,以及读取/回放路径。 +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个 session 一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、中断 turn 恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 -多个后端共享同一磁盘会话时,通过[共享持久化写协调器](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 +共享同一磁盘 session 的多个后端通过[共享持久化写入协调器](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index e68f9b2c75..1bd49e2273 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -sandbox.md: be8e3cd60681077ff5036915fd99520fe9685140 -sandbox.zh.md: ca21c09e7f3d0756f78d7bd1581b9b4b03a43815 +sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec +sandbox.zh.md: dd02041dea998221f1fa7549cb829791137b5088 diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index ca21c09e7f..dd02041dea 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -11,39 +11,90 @@ `SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入(必需的 `/dev/null` 接收器除外);`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv +/** + * File-effect policy for confined processes. `read-only` permits only required + * sinks such as `/dev/null`; `workspace-write` also permits the workspace and a + * backend-defined temp area; `danger-full-access` bypasses confinement. Network + * and process visibility are outside this vocabulary. + */ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' ``` 只有前两种模式可以发送给提供方。`danger-full-access` 的消费方直接 spawn 原始 argv,不调用 `ctx.sandbox`。 ```ts type-equiv +/** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */ type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'> ``` 强制执行程度是一个报告事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。 ```ts type-equiv +/** + * Enforcement completeness for this host. `partial` means an active backend or + * older kernel ABI cannot govern every promised file effect; callers requiring + * an absolute boundary must not treat it as `full`. + */ type SandboxEnforcement = 'full' | 'partial' ``` ## 逐调用策略 -策略在每次调用时完全解析并随调用携带。这使得并发消费方和一次性提权重试能够向同一个提供方请求不同的边界,而无需修改提供方状态。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用 session 的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent 时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 ```ts type-equiv -interface SandboxPolicy { +/** + * The complete file-effect policy resolved for one capability call. The root + * is carried even under modes that do not consume it so callers can resolve + * policy once before choosing the enforcement path. + */ +interface SandboxExecutionPolicy { /** The file-effect mode this execution runs under. */ - mode: ConfinedSandboxMode + mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string } ``` +`ctx.sandboxPolicy.resolve()` 接收活跃 session;对于已批准的重试,还接收显式模式。该服务拥有优先级与 root 回退规则,使 bash 和 fs 不必重复实现。 + +```ts type-equiv +/** Inputs that select the sandbox policy for one capability call. */ +interface SandboxPolicyRequest { + /** Calling session; its immutable cwd becomes the workspace boundary. */ + session?: Session + /** Explicit approved mode override, which outranks session policy. */ + mode?: SandboxMode +} +``` + +只有受约束的执行会到达 `ctx.sandbox`;其提供方策略在保留同一 root 的同时收窄模式。这使并发 session、消费方与一次性提权重试可以向同一提供方请求不同边界,而无需改变提供方状态。 + +```ts type-equiv +/** + * What one confined execution is allowed to touch — carried PER CALL, not + * fixed on the provider: two consumers may confine under different policies + * at the same instant (bash under `read-only` while a confined child agent + * needs its state directory writable), and an approved escalated retry is a + * new call with a wider policy. Defaulting/resolution is an explicit step at + * the consumer boundary; the provider treats the policy as fully specified. + */ +interface SandboxPolicy extends SandboxExecutionPolicy { + /** The file-effect mode this execution runs under. */ + mode: ConfinedSandboxMode +} +``` + ## 包装后的 argv 与分类方言 `ConfinedArgv` 是消费方实际 spawn 的内容。除了替换后的 argv,它还携带后端的强制执行事实和两种正交的 stderr 方言。`denialSignatures` 用于识别沙箱正常工作时被隔离命令被阻止的情况。`runnerFailureSignatures` 用于识别沙箱运行器在执行命令之前拒绝或失败的情况;消费方应先检查后者,将其作为沙箱基础设施故障上报,而非普通任务失败。 ```ts type-equiv +/** + * A {@link SandboxProvider.confine} result: the argv to spawn in place of + * the caller's own, plus the enforcement completeness the selected backend + * achieves for it. + */ interface ConfinedArgv { /** The wrapped argv (runner, profile, separator, then the caller's argv). */ argv: string[] @@ -59,17 +110,9 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * How the RUNNER ITSELF failing identifies itself: case-insensitive stderr - * substrings produced when the sandbox binary is missing, refuses its - * profile, or fails closed before exec'ing the command (`bwrap: `, - * `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own - * error prefix and the shell's runner-not-found message). ORTHOGONAL to - * {@link denialSignatures}: a denial is the confined COMMAND being blocked - * (the sandbox working as designed); a runner failure means the command - * NEVER RAN and must surface as a sandbox failure, not a task failure — - * consumers check these signatures FIRST (a runner's own error text may - * contain denial words, e.g. an unopenable grant root reporting - * `Permission denied`). + * Case-insensitive signatures for runner failure before command execution. + * Consumers check these before denial signatures: runner failure means the + * command never ran, while denial means confinement worked and blocked it. */ runnerFailureSignatures: readonly string[] } diff --git a/docs/core-data-structures/scope.i18n.yaml b/docs/core-data-structures/scope.i18n.yaml index 6d3acddaff..4067956fe6 100644 --- a/docs/core-data-structures/scope.i18n.yaml +++ b/docs/core-data-structures/scope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -scope.md: f95594329ee9ac83da2efcc31df377c53e64331a -scope.zh.md: be423e62def89e03f15d438b9fd9db6fd41b68f3 +scope.md: 73a697f2843293daffff85dabf4656346f7dcd04 +scope.zh.md: b5cc21cfb3d3dbb3890d179f9a813dccb1c318ec diff --git a/docs/core-data-structures/scope.zh.md b/docs/core-data-structures/scope.zh.md index be423e62de..b5cc21cfb3 100644 --- a/docs/core-data-structures/scope.zh.md +++ b/docs/core-data-structures/scope.zh.md @@ -2,21 +2,27 @@ [English](scope.md) | 中文 -[scope 包(package)](../../packages/core/scope)提供身份标识与载体词汇,使一个注册上下文同时表达逐 agent(智能体)的可见性与共享的生命周期归属。它是一个库级原语,而非 Cordis 服务;[agent-scope 运行时设计 RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) 阐述了实现原理,包的 [README](../../packages/core/scope/README.md) 说明了可调用 API 与过滤语义。 +[scope 包](../../packages/core/scope)提供 identity、carrier 与 scoped-layer 词汇,使同一个注册 context 同时代表逐 agent 可见性和共享生命周期所有权。它是库原语,而不是 Cordis 服务;生命周期设计理由由 [agent-scope 运行时设计 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer)规定,registry-layer 决策由[共享存储 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)规定,可调用 API 与过滤语义则由包 [README](../../packages/core/scope/README.md)规定。 -源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts)。 +源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) 与 [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts)。 ## 身份标识与分发载体 `ScopeKey` 是一个不透明的对象身份标识。已交付的 agent loop(智能体循环)使用活跃的 `Agent` 对象作为自身的 key,但该原语从不检视该对象。 ```ts type-equiv +/** An opaque, identity-compared scope key. */ type ScopeKey = object ``` `Scoped<T>` 是编译期品牌标记,标注在 `scopeTarget(base, key)` 返回的不透明路由接收器上。作用域过滤的事件声明要求以此载体作为 `this` 类型,而真正的事件主体仍作为显式参数传入。 ```ts type-equiv +/** + * A routing-only event receiver built by {@link scopeTarget}. The type + * parameter records the subject type for dispatch checking; the carrier does + * not expose the subject's properties. Event payloads carry the real subject. + */ type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } ``` @@ -25,9 +31,29 @@ type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } `Scope` 将带标签的注册上下文与两个拆卸接口配对。`rawDispose` 保留有序复合 effect 所需的精确 Cordis disposer 身份;`dispose()` 是面向直接调用方和竞态调用方的公共停稳边界。 ```ts type-equiv +/** A minted registration scope and its quiescent disposal boundaries. */ interface Scope { + /** Context through which scope-owned registrations are made. */ ctx: Context + /** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */ rawDispose: () => Promise<void> | void + /** Dispose every scope-owned registration; racing calls await the same completion. */ dispose(): Promise<void> } ``` + +## 带作用域的注册表层 + +`ScopeLayer` 表示一个注册表在全局或确切作用域层级的完整贡献。具体 layer 可以聚合多个具名与匿名 table;整个 layer 为空时,`ScopedLayers` 可以回收带作用域状态,而不会丢弃兄弟 table。 + +```ts type-equiv +/** One scope's aggregate contribution to a registry. */ +interface ScopeLayer { + /** Whether every table in this layer is empty. */ + isEmpty(): boolean +} +``` + +`ScopedLayers<L>` 拥有立即创建的全局 layer,以及惰性创建的确切作用域 layer。读取不会创建 layer:`peek(undefined)` 表示没有 overlay,而 `merge()` 会物化按插入顺序排列的全局具名 entry,随后是带作用域的 shadow。注册使用同一个 context 表示可见性与 Cordis effect 所有权,在可选通知前收集一个同步 undo,返回 Cordis 的确切 disposer,并且只在带作用域 layer 的完整 `ScopeLayer` 为空时回收它。 + +`NamedEntries<V>` 提供按插入顺序的查找与 live iteration,重复错误由调用方所有。`AnonymousEntries<V>` 为每次 append 分配唯一标识,使相等的值仍相互独立。迭代在同一非空 table generation 内保持 live;排空 table 会让现有 iterator 与后续插入脱离。两者都返回幂等的确切 entry undo;共享的 `EntryValues` 实现接口不公开。 diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index ff5bae0fba..297fdf7748 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session-query.md: 444f2bb2256a43df7bd8521dfe234f771eec7181 -session-query.zh.md: 70f9737a702f84074962d9d1c7ac49a7c119f8cf +session-query.md: 4f977b933225e4873466a379171ec3db2596a14e +session-query.zh.md: 864dd213102c1452337e66800db4cb828a1edb8b diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 70f9737a70..864dd21310 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -2,7 +2,7 @@ [English](session-query.md) | 中文 -对实时优先的逻辑会话语料库进行精确读取。[包(package)契约](../../packages/session-query/session-query)定义了源优先级、动态可选持久化、克隆、surface 分类、有界窗口与类型化错误。全文搜索是另一个拟议的 SQLite 阶段。 +对优先使用 live 数据的逻辑 session 集合执行精确读取与关系追踪。[包契约](../../packages/session-query/session-query)拥有来源优先级、动态可选持久化、克隆、surface 分类、有界窗口、追踪校验与类型化失败。全文搜索属于另一个拟议的 SQLite 包。 源码:[`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) @@ -11,58 +11,153 @@ `SessionRecord` 由跨语料库列表返回。它独立于克隆后的实时优先 header 暴露源可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与 model-history 推导相同的 `foldSurface()` 状态转换。 ```ts type-equiv -export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' +/** Whether an event is current model context, replaced context, or raw-log-only. */ +type SessionEventSurface = 'current' | 'shadowed' | 'log-only' ``` ```ts type-equiv -export interface SessionRecord { +/** Lightweight identity and source availability for one logical session. */ +interface SessionRecord { + /** Cloned session header selected from the live-preferred corpus. */ header: SessionHeader + /** Whether the id currently exists in `ctx.sessions`. */ live: boolean + /** Whether the active persistence backend currently materializes the id. */ persisted: boolean } ``` ```ts type-equiv -export interface SessionEventRecord { +/** Lightweight metadata for one event within a logical session. */ +interface SessionEventRecord { + /** Session that owns the event. */ sessionId: SessionId + /** Monotonic event seq within the session. */ seq: number + /** Discriminant of the session event. */ type: SessionEventType + /** Event timestamp in Unix epoch milliseconds. */ time: number + /** Event placement in the folded session surface. */ surface: SessionEventSurface } ``` +## Session 谱系 + +`SessionLineageTrace` 按由近及远的顺序携带已知 parent,并携带一片由直接 descendant 递归嵌套而成的森林。完整性判别字段使已知 root 与缺失 parent 互斥。 + +```ts type-equiv +/** Recursive descendant node in a session-lineage trace. */ +interface SessionLineageNode { + /** Detached logical-corpus record for this descendant. */ + session: SessionRecord + /** Direct children, each carrying its own recursive descendants. */ + descendants: SessionLineageNode[] +} +``` + +```ts type-equiv +/** Known ancestry and descendants for one logical session. */ +type SessionLineageTrace = { + /** Detached record for the session that was traced. */ + target: SessionRecord + /** Known parents from the immediate parent outward. */ + ancestors: SessionRecord[] + /** Complete known descendant trees rooted at the target's direct children. */ + descendants: SessionLineageNode[] +} & ( + | { + /** The complete parent chain is present in the logical corpus. */ + complete: true + /** Detached record at the top of the complete lineage. */ + root: SessionRecord + } + | { + /** The parent chain leaves the visible logical corpus. */ + complete: false + /** First parent id that is not present in the logical corpus. */ + unresolvedParentId: SessionId + } +) +``` + ## 有界事件读取 请求指定一个原始 seq 及可选的邻近数量。结果携带 `SessionHeader` 而非可用性标志,使已知的实时目标可以独立于持久化健康状态。 ```ts type-equiv -export interface SessionEventReadRequest { +/** Request for one event plus raw neighboring log context. */ +interface SessionEventReadRequest { + /** Session that owns the target event. */ sessionId: SessionId + /** Target event seq. */ seq: number + /** Number of preceding raw events to include. */ before?: number + /** Number of following raw events to include. */ after?: number } ``` ```ts type-equiv -export interface SessionEventWindow { +/** Full target event and a bounded raw-log window. */ +interface SessionEventWindow { + /** Cloned header for the live-preferred source read. */ session: SessionHeader + /** Full cloned target event. */ target: SessionEvent + /** Full cloned events from `startSeq` through `endSeq`. */ events: SessionEvent[] + /** First seq included in `events`. */ startSeq: number + /** Last seq included in `events`. */ endSeq: number } ``` +## 事件关系 + +事件追踪会区分位置性的 surface 替换与已记录 provenance。除 `replacementChain` 外,每个 seq 列表都包含直接链接;该链从目标沿直接 replacer 追踪到最终的位置替换。 + +```ts type-equiv +/** Request for direct surface and provenance relationships around one event. */ +interface SessionEventTraceRequest { + /** Session that owns the target event. */ + sessionId: SessionId + /** Target event seq. */ + seq: number +} +``` + +```ts type-equiv +/** Direct surface and provenance relationships for one event. */ +interface SessionEventTrace { + /** Lightweight target record. */ + target: SessionEventRecord + /** Immediate positional replacement event, when the target was shadowed. */ + replacedBy?: number + /** Positional replacers from the immediate replacement to the final replacement. */ + replacementChain: number[] + /** Surface nodes directly removed when the target itself performed a replacement. */ + replacedEventSeqs: number[] + /** Direct logged provenance sources in their recorded order. */ + sourceEventSeqs: number[] + /** Later events that directly name the target as a provenance source, in log order. */ + derivedEventSeqs: number[] +} +``` + ## 错误 封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端故障与矛盾的源元数据。 ```ts type-equiv -export type SessionQueryErrorCode = +/** Stable machine-routable failure taxonomy for exact session reads and traces. */ +type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_LINEAGE' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 33e1d86f7d..8f2f0b2f8a 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session.md: 796abebbc31a54c7c341028cf0a09031ae59cd78 -session.zh.md: 04bfe84f079762091e7c4a4d77db69c053e6ea30 +session.md: d3c6ab65df29ef0df504a0a01219e36ebad8e8fd +session.zh.md: 7d5e03f6b8d74e7b2dc0097acb1f5f78f6d3af6e diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 04bfe84f07..7d5e03f6b8 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -11,30 +11,54 @@ 仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[上下文压缩(context compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件,用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。 ```ts type-equiv +/** + * The merge-extensible, append-only source of truth for an agent interaction. + * Message history is derived from this log. Every event is lossless JSON and + * sequence numbers stay contiguous, including raw chunks, so persistence can + * store the canonical log verbatim. + */ interface SessionEventMap { + /** + * Opens turn `turn`. `trigger` records what started it — one claimed queued + * message or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ 'turn/start': { turn: number; trigger: TurnTrigger } + /** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. + */ 'turn/end': { turn: number; reason: TurnEndReason } + /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ 'step/start': { turn: number; step: number } + /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (queued message drained at turn start). */ + /** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** - * A queued prompt an `agent/prompt-submit` listener VETOED — the durable - * record of a blocked prompt and why. Appended in place of the `user/message` - * the prompt would have become, so the block survives replay even in a MIXED - * batch where another queued prompt is allowed (there the turn does not end - * `rejected`, so the boundary reason alone would not preserve it). `content` - * is the original prompt the listener rejected; `reason` is the veto text - * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a - * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, and its turn runs zero steps. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history - * as tagged synthetic context — NOT a user prompt. + * as a synthetic user-role message carrying `content` verbatim — NOT a + * user prompt. `meta` is durable JSON state omitted from the model + * projection; it is also the intended channel for any future framing + * directive (a producer declares the frame, a dedicated renderer applies it — + * see the deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. */ - 'context/message': { content: ContentBlock[]; source: MessageSource } + 'context/message': { + content: ContentBlock[] + source: MessageSource + meta?: JsonValue + } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -43,69 +67,85 @@ interface SessionEventMap { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. */ - 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } + /** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + /** + * A completed tool call's model-facing result, plus an optional tool-private + * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the + * producing tool owns its shape and reads it back in `presentResult`) but MUST + * be JSON-serializable: `Session.append` runtime-validates all event data with + * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the + * durable log reproduces the identical card on replay. Absent unless the tool + * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + */ 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } - /** - * The agent's whole todo list, carried as a full snapshot and replaced - * wholesale on each write — the current list is the most recent `todo/write` - * (last-write-wins on replay, no fold). Appended by an owning agent via - * `session.append('todo/write', { todos })`. - * - * NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches - * `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — - * it is durable, replayable UI state, distinct from the conversation history. - * It is a `SessionEventMap` member riding the existing `session/event` emit, - * not a first-class Cordis `interface Events` notification, so it has no - * cordis-catalog row. - */ + /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** - * Full snapshot of the {@link EpochHeader} the NEXT request is built under, - * with the {@link RequestHeaderReason} it was recorded whole. Appended by - * the loop inside the step, before dispatch, on a loop instance's first - * request-building step (`'initial'`/`'resume'`) or when a delta failed its - * round-trip guard (`'fallback'`); always records what the request actually - * used, post-`agent/request`. Anchors the header fold: reconstruction reads - * the latest snapshot and applies the deltas after it. NOT a - * {@link SurfaceEventType}: it produces no LLM message — it is the request - * envelope, logged so every request is a pure function of the session log - * (the reconstructability RFC). + * Full header for the next request, appended inside its step before dispatch. + * It is log-only; the latest snapshot reconstructs the request header. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } - /** - * Amendment to the folded {@link EpochHeader}: system line-trim, name-keyed - * tools delta, whole replacement config, or whole replacement session - * prefix (an EMPTY array encodes the transition to "none"). The - * writer verifies `applyHeaderDelta(previous, delta)` reproduces the new - * header exactly and falls back to a `'fallback'` `request/header` snapshot - * when it cannot, so a logged delta ALWAYS round-trips. NOT a - * {@link SurfaceEventType}. - */ - 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } ``` +### `OutOfBandSessionEventMap`:受限的带外追加显式准入 + +仅属于 `SessionEventMap` 并不表示事件可以脱离 agent loop 的常规生命周期追加。事件所有方必须通过声明合并将同一键加入这个空标记映射,`ctx.sessions.appendOutOfBand()` 才会接受该事件;派生类型还会排除所有 surface 事件。被接受的更新会并入已打开的轮次;如果没有打开的轮次,系统则为它创建一个边界配平且已刷新完成的零步骤轮次。 + +```ts type-equiv +/** + * Marker map for plugin-owned log-only events accepted by + * `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key + * it adds to {@link SessionEventMap}; surface and lifecycle events stay + * ineligible unless their owner explicitly opts them into this narrow seam. + */ +interface OutOfBandSessionEventMap {} +``` + ### `TodoItem`:一条待办项 -`todo/write` 事件全量快照的单元。刻意保持精简:一行 `content` 加一个三态 `status`(无 id、无优先级、无 `activeForm`)。列表在每次写入时整体替换,因此条目不需要稳定标识;三态 status 恰好是 ACP(Agent Client Protocol)的 `PlanEntryStatus`,UI 桥接层可以将待办列表 1:1 映射到 ACP `plan`(再合成 ACP 额外要求的优先级)。见 [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md)。 +这是 `todo/write` 事件全量列表快照中的单元。它有意保持精简:一行 `content` 加一个三态 `status`(没有 id、优先级或 `activeForm`);列表在每次写入时整体替换,因此条目无需稳定标识,而这三个状态值恰好对应 ACP 的 `PlanEntryStatus`,所以 UI 桥接层可以将待办列表一一映射为 ACP `plan`(并合成 ACP 额外要求的优先级)。见 [todo_write Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md)。 ```ts type-equiv -export interface TodoItem { +/** + * One entry in an agent's todo list — the unit of the `todo/write` + * {@link SessionEventMap} event's whole-list snapshot. + * + * Deliberately minimal: a human-readable `content` line and a three-state + * `status`. No id, priority, or `activeForm` — the list is replaced wholesale + * on every write (last-write-wins), so entries need no stable identity, and the + * status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a + * todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally + * requires). + */ +interface TodoItem { + /** What this task is — a short imperative line shown in the UI. */ content: string + /** Lifecycle state. `in_progress` marks the single task being worked now. */ status: 'pending' | 'in_progress' | 'completed' } ``` -### 请求头事件:`request/header` 与 `request/header-delta` +### 请求头事件:`request/header` -请求信封(`EpochHeader`:调用配置 + 渲染后的系统提示词 + 组装好的工具 schema + 会话前缀)是被记录到日志中的会话状态,使得每次对话请求都是日志的纯函数(可重建性 RFC)。`request/header` 快照(reason 为 `'initial' | 'resume' | 'fallback'`)在对话诞生、进程边界和 delta 编码回退时锚定折叠点;`request/header-delta` 事件在运行中修正它。`foldRequestHeader(events)` 可重建任一请求构建时所用的 header;写入器在记录每个 delta 前都会做往返验证,因此格式正确的日志总能折叠。两者都不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema + 会话前缀)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv -export interface EpochHeader { - /** The conversation's call configuration (model + sampling scalars). */ +/** + * Logged request state outside derived history: call config, system prompt, + * tools, and prefix. The latest full `request/header` snapshot reconstructs it; + * canonical empty optional fields are absent. + */ +interface EpochHeader { + /** The conversation's call configuration (provider, model, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string @@ -122,13 +162,26 @@ export interface EpochHeader { } ``` -规范形式:空的系统提示词、空的工具列表和空的会话前缀均为 ABSENT 字段,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop(智能体循环)实例组合一次,由该实例的快照锚定,因此实际上 loop 不会产生前缀 delta。delta 分支(整数组替换,空数组编码「回到无前缀」的转换)存在是为了编解码的完备性。其他 delta payload(`SystemDelta`:公共前缀/后缀行裁剪;`ToolsDelta`:按名称键控的增/删/改)与事件一起定义在 [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)。 +规范形式:空系统提示词、空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop(智能体循环)实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 ## `SessionEvent<T>`:一条日志条目 基于 `type` 的真正可辨识联合(而非独立的 `type`/`data` 联合),因此 `switch (event.type)` 能直接收窄 `event.data`,无需类型断言。`seq` 是日志中的单调递增位置(`seq = log.length`);`time` 为 epoch 毫秒。 ```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ type SessionEvent<T extends SessionEventType = SessionEventType> = { [K in SessionEventType]: { type: K @@ -141,7 +194,9 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction replace node). + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ @@ -152,14 +207,21 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { `SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 语句禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。 +对于 `assistant/message`,存在的 `sourceEventSeqs: []` 表示提供方流已知且完整地为空;字段缺失则表示旧格式或其他未记录溯源信息的情况。agent loop 会为每次成功的模型调用写入该字段;其他 surface 事件只要包含该字段,其列表就必须非空。 + ## Surface 类型 -五种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`)携带 surface 元数据,声明它们如何加入派生的 surface 链表。见 [session surface RFC](../rfc/implemented/architecture/2026-06-18-session-surface.md)。 +五种产生消息的类型(`SurfaceEventType`:`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)。 ### `SurfaceEventType`:事件类型中产生消息的子集 ```ts type-equiv -export type SurfaceEventType = +/** + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the ordered surface. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' @@ -170,64 +232,221 @@ export type SurfaceEventType = ### `SurfaceOp`:事件如何进入 surface ```ts type-equiv -export type SurfaceOp = +/** + * How a session event entered the ordered surface. Only valid on + * {@link SurfaceEventType} events. + * + * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * messages. + * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` + * (inclusive) through `end` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `start === end` replaces a single + * node. The node's {@link SessionEvent.sourceEventSeqs} must include every + * shadowed surface node. Used by compaction and possible other manipulations. + */ +type SurfaceOp = | 'append' | { op: 'replace'; start: number; end: number } ``` -`'append'` 是正常的尾部追加路径。`replace` 遮蔽从 `start` 到 `end`(含两端)的 surface 节点(两者都必须是有效的 surface 节点 seq;`start === end` 时只替换一个节点),并在其位置插入新节点。 +`'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。 ### `SurfaceIntent`:`session.append()` 的参数 ```ts type-equiv -export interface SurfaceIntent { +/** + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. + */ +interface SurfaceIntent { surfaceOp: SurfaceOp + /** + * Complete known provenance source set. `assistant/message` may use a + * present empty array for a known empty provider stream; omission means its + * provenance was not recorded. Other surface events require a non-empty set + * when this field is present. + */ sourceEventSeqs?: number[] } ``` 对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。 -### `SurfaceNode`:surface 链表中的一个节点 +此处适用相同的溯源区分:只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;省略该字段并不表示其源流为空。 + +### `SessionSurface`:实时只读 surface 投影 + +`Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。 ```ts type-equiv -export interface SurfaceNode { - seq: number - prev: number | null - next: number | null +/** Readonly live projection of the message-producing session events. */ +interface SessionSurface { + /** Current surface event sequences in model-visible order. */ + readonly nodes: readonly number[] + /** Monotonic count of committed positional replacements. */ + readonly replaceGeneration: number } ``` ### `SurfaceFoldReplacement` 与 `SurfaceFoldResult`:完整的 surface 回放 -`foldSurface(events)` 返回当前分离的节点,以及每个声明的替换范围实际遮蔽的节点 seq。`SurfaceManager` 对其增量缓存使用相同的转换函数。 +`foldSurface(events)` 返回一份独立的当前事件 seq 列表,以及每个声明的替换范围实际遮蔽的 seq。实时管理器复用同一套状态转换,但不保留替换历史。每提交一次替换,其 `replaceGeneration` 就递增一次,使增量消费方能够区分纯尾部增长与重写。 ```ts type-equiv -export interface SurfaceFoldReplacement { +/** One replacement operation observed while folding a session surface. */ +interface SurfaceFoldReplacement { + /** Seq of the event that replaced the prior surface range. */ seq: number + /** Declared inclusive start seq of the replaced surface range. */ start: number + /** Declared inclusive end seq of the replaced surface range. */ end: number + /** Actual surface entries removed by the operation, in surface order. */ shadowedSeqs: number[] } ``` ```ts type-equiv -export interface SurfaceFoldResult { - nodes: SurfaceNode[] +/** Complete result of replaying the surface operations in a session log. */ +interface SurfaceFoldResult { + /** Current surface event sequences in model-visible order. */ + nodes: number[] + /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] } ``` +## `Session` public API + +去除方法体的声明与源码中的普通类保持同步,覆盖其公共构造函数、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 服务目录](../cordis-catalog/services.md#ctxsessions--sessionstore)记录。 + +```ts public-api +/** + * An event-sourced session: an append-only log of {@link SessionEvent}s. + * + * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Seeding with an existing event log replays/forks a session. + */ +declare class Session { + /** The ordered surface over this session's event log. */ + get surface(): SessionSurface; + /** + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * `session.header` is always present. Kept out of the event log — it is a + * storage concern, not replayable conversation state. + */ + readonly header: SessionHeader; + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId; + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * An immutable snapshot of the append-only event log. The snapshot is reused + * until the next append; a previously returned array does not grow later. + * Events and their nested data are deep-frozen at acceptance, so neither a + * cast nor ordinary JavaScript can rewrite durable history. + */ + get events(): readonly SessionEvent[]; + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ + get seq(): number; + /** + * Append one typed event to the log and synchronously notify observers via + * the store-owned, module-private publication hooks. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. Once the event enters + * the log, the append is committed: observer failures are logged and + * contained per listener, so they do not change the return value or prevent + * later listeners from observing the same accepted event. + * + * @param type - The event type (key of {@link SessionEventMap}). + * @param data - The event payload; must be JSON-serializable. + * @param opts - Surface metadata: `surfaceOp` controls how the event enters + * the ordered surface; `sourceEventSeqs` records provenance (the seq + * numbers of events this one derives from). REQUIRED for + * {@link SurfaceEventType} events (every message-producing event must + * declare how it joins the surface, the sole source of derived history) and + * rejected by the compiler for non-surface types like `turn/start` or + * `assistant/chunk`. + * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of + * `data` that entered the log, so reading `event.data` back sees the logged + * value, never the caller's still-mutable input. + * @throws if `data` or surface metadata is not losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * to validation and another to storage. The event log is the durable source + * of truth, so a bad event fails at the append site rather than later during + * a backend flush. A synchronous internal dispatch validation failure or an + * append reentered while this acceptance/publication boundary is open also + * rejects before the log changes. + */ + append<T extends SessionEventType>( + type: T, + data: SessionEventMap[T], + ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] + ): SessionEvent<T>; + /** + * The {@link EpochHeader} in force after the log's last header event — the + * header the NEXT request will be compared against — or undefined before + * the first `request/header` snapshot. The live, incrementally-maintained + * form of `foldRequestHeader(session.events)`: each header event is folded + * once, when first seen, so a per-step read costs O(new events). + * @returns the folded header, or undefined when no header event exists yet. + */ + requestHeader(): EpochHeader | undefined; + /** + * Derive the LLM message history by walking the ordered sequences of + * message-producing events maintained by `surfaceOp` markers. The + * surface is the single source of derived history: every message-producing + * append records its `surfaceOp`, so a raw event with no marker (a chunk, a + * turn boundary) is correctly absent, and a compaction `replace` deletes the + * shadowed nodes from the derivation. The projection rules are + * {@link deriveEventMessage}, folded per node. + * + * CACHED: each surface node is projected exactly once, when first seen — a + * call costs O(new nodes), and a surface rewrite (a `replace`; + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * a fresh snapshot per call (later appends never grow an array a caller + * already holds); the `Message` objects in it are SHARED and **deep-frozen**. + * Their content reuses the already frozen durable event data, so the cache + * needs no second deep clone and consumers still cannot mutate the log. + * @returns a fresh array of the shared, frozen derived history. + */ + deriveMessages(): Message[]; + /** + * Project a single event into the LLM message it derives to, or null when + * it produces none — a non-surface event (chunk, boundary, log-only record) + * or an empty-content assistant/message (which exists only to host usage). + * The per-node pure function {@link deriveMessages} folds over the surface; + * an external reconstructor (or the dev invariant) folds the same function + * over a log prefix's surface to rebuild the exact messages any request was + * built from (the reconstructability Agent Note). The returned message wrapper is + * fresh; its content reuses the logged event's already deep-frozen durable + * data, so changing the wrapper cannot rewrite the log and changing content + * throws. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ + deriveEventMessage(event: SessionEvent): Message | null; +} +``` + ## 派生历史:`deriveMessages()` 与 `deriveEventMessage()` `Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: - `user/message` → 一条 user 消息。 -- `assistant/message` → 一条 assistant 消息。原始 `assistant/chunk` 事件是回放/UI 数据,在派生中被**跳过**(组装后的消息才是权威的)。**空内容**的 `assistant/message` 也被跳过:一个因 max-tokens 截断且无内容的步骤仍会记录 `assistant/message` 以承载其 `usage`,但无内容的 assistant 轮次不得进入提供方的 transcript(文本记录)。 +- `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。 - `tool/result` → 一条携带 `tool-result` 块的 user 消息。 -- `context/message`、`steering/message` → 以 user 角色、按时间顺序插入的消息,包裹在标记信封中(`<context source="…">…</context>`),即「系统提醒」模式;模型通过信封区分它们与真实提示词。 +- `context/message` → 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`。可选的 JSON `meta` 保留在事件日志中,绝不渲染。 +- `steering/message` → 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其内容。 -其他一切(`turn/*`、`step/*`)是结构性事件,不投影为消息。token 用量通过 `assistant/message.usage` 观察(产生该用量的步骤);操作错误的步骤号在 `turn/end.reason` 中(`kind: 'error'` 时)。 +其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 ## 活跃会话 fork API @@ -240,6 +459,10 @@ export interface SurfaceFoldResult { ## 轮次的触发原因:`TurnTriggerMap` ```ts type-equiv +/** + * What started a turn. + * Merge-extensible sum type (same pattern as MessageSourceMap). + */ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** @@ -256,57 +479,57 @@ interface TurnTriggerMap { ## 轮次的结束原因:`TurnEndReasonMap` +`aborted` 有意作为一种粗粒度的持久结果:它只记录取消中断了实时轮次,不记录是哪个运行时调用方发起取消。仅属于运行时的调用方词汇由 [`AgentCancelCause`](core.md#the-agent-handle) 定义;未来若有审计需求,应新增独立的控制请求事件,而非让终止结果承载这一信息。 + ```ts type-equiv +/** + * Why a turn ended. Merge-extensible sum type. + */ interface TurnEndReasonMap { completed: { kind: 'completed' } - aborted: { kind: 'aborted'; reason?: string } + /** A cancellation request interrupted the live turn. */ + aborted: { kind: 'aborted' } /** * The turn failed: a step threw or the model reported a failure. `step` is the * step number the failure occurred on (the operational error's location — the * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). `code` is the error's code when one was attached. + * `agent/error`). Final model-request failures retain their normalized facts + * as one `failure`; other turn failures retain their live Error message/code. */ - error: { kind: 'error'; step: number; message: string; code?: string } + error: { kind: 'error'; step: number } & ( + | { failure: LlmFailure; message?: never; code?: never } + | { message: string; code?: string; failure?: never } + ) disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block - * is a durable in-turn fact), but ran zero steps. `reason` carries the block - * message from the vetoing decision. Distinct from `aborted` (a user-driven - * cancel) and `error` (a failure): the prompt was rejected by policy, not - * interrupted or broken. A UI renders it as "prompt blocked by hook". + * Policy blocked the turn's claimed prompt before the first step. The + * zero-step turn still records a balanced durable boundary and veto reason. */ rejected: { kind: 'rejected'; reason: string } /** - * The turn never ended on its own: the process crashed mid-turn and a - * persistence backend later closed the orphaned (open) turn on reload so the - * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no - * loop ever emits this. Its events are real (they were durably appended before - * the crash) and are PRESERVED, not discarded: a single turn can be huge in a - * long-horizon task (many steps, large tool output), so truncating it would - * lose real work. The marker records that the turn was cut short, not that the - * model completed it. See the session-persistence RFC. + * A persistence backend closed a crash-orphaned turn on reload. The loop never + * emits this marker, and the events recorded before the crash remain intact. */ interrupted: { kind: 'interrupted' } } ``` -`max-tokens` 对应同名的模型调用 `FinishReason`:轮次中任何一个步骤出现 `max-tokens`,整个轮次就以 `max-tokens` 结束而非 `completed`(截断事实优先于后续的继续),消费方据此区分正常停止与被截断的情况。但这仅相对于 `completed` 而言:`disposed`/`aborted`/`error` 结果优先级更高。`rejected` 是一个零步骤轮次,其整批提示词被 `agent/prompt-submit` 钩子阻止(ACP 桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不由 loop 发出的原因,由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止(ACP 桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 ## 轮次封闭不变式 -每个会话事件都存在于一个轮次**内部**(位于 `turn/start` 与其对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加排队的 `user/message` 事件;空闲时的 `agent.inject()` 将其 `context/message` 包裹在一个一次性的 `injection` 轮次中。这使得轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为中断崩溃的尾部,而不会误丢合法记录的轮次间上下文。`dsh-invariants` 插件在开发环境中强制执行此不变式(在无打开轮次时追加消息事件会抛出异常)。见[轮次封闭不变式 RFC](../rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 +每个会话事件都位于一个轮次**之内**(在 `turn/start` 和对应的 `turn/end` 之间)。loop 在 `turn/start` *之后*追加已排队的 `user/message` 事件;空闲时的 `agent.inject()` 会用一次性的 `injection` 轮次包住其 `context/message`;没有打开的轮次时,`appendOutOfBand()` 同样会用一个轮次包住符合条件的仅日志事件。这使轮次成为唯一的持久性/回放边界:后端可以将最后一个 `turn/end` 之后的任何内容视为崩溃中断尾部,而不会丢失合法记录在轮次之间的上下文。可选的 `dsh-session/invariant` 配套插件通过 `ctx.invariants` 在开发环境中强制此不变式(消息事件若位于打开的轮次之外便会抛出)。见[轮次封闭不变式 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)。 ## 插件贡献的仅日志事件 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 -钩子桥接的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 打开的轮次内触发,因此其 `hook/*` 记录天然满足轮次封闭。`SessionStart` 不产生 `hook/*` 记录(其注入的 `context/message` 就是持久证据),因为它没有打开的轮次来容纳记录(见[钩子桥接 RFC](../rfc/implemented/feature/2026-06-30-hook-bridges.md))。 +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `context/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 -持久化后端所依赖的约定:持久日志逐字保存每个事件,**包括** `assistant/chunk`。`seq` 必须保持连续,因此不能从规范日志中过滤掉 chunk。所有 `event.data` 必须可 JSON 序列化;`Session.append` 在源头强制执行此约束(对不可序列化的数据抛出异常),因此坏事件永远不会进入日志,`session.events` 始终等于后端能持久化的内容。添加一个携带不可序列化数据的事件类型,或破坏不变式插件所检查的 turn/step 嵌套结构,都是对磁盘格式的破坏性变更。 +持久化后端依赖的契约如下:持久日志原样保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。 消费此契约的后端见 [persistence.md](persistence.md)。 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 32c97d6b0a..e71bfaa45c 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -skills.md: b0a847cec05651b63e63de96423170a0fd7ca2a9 -skills.zh.md: 201bf53e95b5cbf5f8873217acca3c478cf30860 +skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 +skills.zh.md: ef67f06b69ac321d1d8bea4ef962a9ac4f6be2dd diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 201bf53e95..ef67f06b69 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -13,9 +13,25 @@ 重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败。 ```ts type-equiv +/** Provider interface for one source of skills, such as local directories or a remote registry. */ interface SkillProvider { + /** Unique provider name in the `ctx.skills` registry. */ readonly name: string + /** + * List available skill candidates for the current lookup context. Provider + * plugins register synchronously during `apply()`; remote initialization, + * authentication, and discovery are awaited inside this method. Implementations + * should settle promptly when `options.signal` aborts. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns provider candidates with precedence ranks and opaque locators. + */ readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]> + /** + * Load a complete skill body for a previously listed candidate. + * @param candidate - the winning candidate originally returned by this provider. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns the full skill body, or `undefined` if it is no longer loadable. + */ readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined> } ``` @@ -39,6 +55,7 @@ interface SkillProvider { skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`<name>/SKILL.md`)和扁平 Markdown 文件(`<name>.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 ```ts type-equiv +/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) ``` @@ -47,13 +64,21 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' `SkillSummary` 是注册表中可供模型调用的摘要形状。消费方自行选择渲染哪些字段;会话目录仅使用 `name` 和 `description`,从不使用 body 或绝对文件路径。`disableModelInvocation` 将 skill 从模型列表中隐藏,但允许受信代码按名称加载。 ```ts type-equiv +/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ interface SkillSummary { + /** Kebab-case identifier used with the `skill` tool. */ readonly name: string + /** Short routing description shown to the model. */ readonly description: string + /** Optional extra routing guidance shown to the model. */ readonly whenToUse?: string + /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ readonly disableModelInvocation?: boolean + /** Discovery source that produced this winning skill. */ readonly source: SkillSource + /** Provider that owns this skill body. */ readonly provider: string + /** Provider-specific base for relative resources. */ readonly resourceBase?: SkillResourceBase } ``` @@ -61,10 +86,15 @@ interface SkillSummary { `SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。 ```ts type-equiv +/** Provider catalog entry used by the registry to merge and later load skills. */ interface SkillCandidate extends SkillSummary { + /** Lower ranks win duplicate skill names before provider registration order is considered. */ readonly rank: number + /** Opaque provider-owned handle passed back to `provider.get()`. */ readonly locator: unknown + /** Absolute file path when the provider has one. */ readonly path?: string + /** Parsed optional metadata object from provider-specific skill frontmatter. */ readonly metadata?: Readonly<Record<string, unknown>> } ``` @@ -72,6 +102,7 @@ interface SkillCandidate extends SkillSummary { `SkillDefinition` 是 `ctx.skills.get()` 返回的完整解析结果,供 `skill` 工具使用。`resourceBase` 告知工具如何为本地、URL 或提供方管理的 skill 渲染相对资源引导。 ```ts type-equiv +/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */ type SkillResourceBase = | { readonly kind: 'directory'; readonly path: string } | { readonly kind: 'url'; readonly url: string } @@ -79,9 +110,13 @@ type SkillResourceBase = ``` ```ts type-equiv +/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */ interface SkillDefinition extends SkillSummary { + /** Markdown instruction body after any provider-specific metadata removal. */ readonly content: string + /** Absolute file path when the skill came from disk. */ readonly path?: string + /** Parsed optional metadata object from frontmatter. */ readonly metadata?: Readonly<Record<string, unknown>> } ``` @@ -89,9 +124,8 @@ interface SkillDefinition extends SkillSummary { 运行时 skill 使用相同的完整形状,参与相同的先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 ```ts type-equiv -type SkillRegistration = Omit<SkillDefinition, 'provider'> & { - readonly provider?: string -} +/** Runtime skill contribution accepted by `ctx.skills.register()`. */ +type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string } ``` ## 查找与配置 @@ -99,8 +133,11 @@ type SkillRegistration = Omit<SkillDefinition, 'provider'> & { skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 ```ts type-equiv +/** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { + /** Workspace selector for the current lookup. */ readonly cwd?: string | undefined + /** Abort discovery or loading work for the current caller. */ readonly signal?: AbortSignal | undefined } ``` @@ -108,13 +145,15 @@ interface SkillLookupOptions { 注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`)。消费方拥有其目录描述上限。 ```ts type-equiv +/** Skill registry configuration. */ interface Config { + /** Maximum number of completed cwd/provider catalogs kept in memory. */ readonly collectCacheMaxEntries?: number } ``` ## 会话目录与工具契约 -`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一条 user-role 的 `<system-reminder>`。目录包含排序后的 skill `name` 和经过规范化、XML 转义的 `description`;不包含 body、路径、来源、提供方和路由提示。前缀发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方配置的描述上限,默认值 `500`,整数最小值 `3`。其仅请求级别、记录于 header 的生命周期由 [session-prefix RFC](../rfc/implemented/feature/2026-07-07-session-prefix.md) 定义。 +`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一条 user-role `<system-reminder>`。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。Prefix 发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。其仅用于请求、记录在 header 中的生命周期由 [session-prefix Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md)定义。 面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index ed154e096c..f7cf2d81de 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -subagent.md: eb9160abaee26969aecdc533fb9fd56fae18b7fa -subagent.zh.md: b6a6bfbe61f5d2d3e0eb3bdc3aabed4b8b89317f +subagent.md: 97d6862c10a0757c41472f207f857c25f3f5d50f +subagent.zh.md: 1e88562aed99122750e9d137cb0b722d4c38c36b diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index b6a6bfbe61..1e88562aed 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -4,7 +4,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM 适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为兄弟包(`dsh-subagent-spawn`、`-fork`、`-acp`);面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计动机见 [subagent RFC](../rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为兄弟包(`dsh-subagent-spawn`、`-fork`、`-acp`);面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) @@ -13,10 +13,22 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba 提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性(steering(中途引导)、resume)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法——方法的存在即为能力,TypeScript 的类型收窄即为发现机制。 ```ts type-equiv +/** + * Which START-TIME features a provider supports. Checked by the service before delegating to + * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks + * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent + * degradation" rule). These static flags cover features needed before a run exists; runtime + * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence + * is the capability. + */ interface SubagentCapabilities { + /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ readonly outputSchema: boolean + /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ readonly depthLimit: boolean + /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ readonly toolFilter: boolean + /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ readonly persona: boolean } ``` @@ -26,28 +38,89 @@ interface SubagentCapabilities { 工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、tool filter 和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture tool 实现所支持的 object-rooted schema。 ```ts type-equiv +/** + * What a caller asks for when starting a subagent. The tool layer builds this + * from the model's `{ description, prompt }` plus its own config; the service + * validates {@link SubagentCapabilities} against the named provider, then + * passes it to {@link SubagentProvider.start}. + */ interface SubagentStartRequest { + /** The task/prompt for the child agent (a user message in the child session). */ readonly prompt: ContentBlock[] + /** + * The spawning ("parent") agent — the one whose tool call started this + * subagent. REQUIRED: in-process backends read `parent.session.header` for + * the working directory, the `parentSession` lineage to stamp on the child, + * and the parent's delegation depth. The out-of-process backend (ACP) reads + * exactly one field — the session header's cwd, the child's workspace when + * no deployment `cwd` override is configured; nothing else crosses the + * process boundary. + */ readonly parent: Agent + /** + * Cancellation signal from the spawning context (the tool's `exec.signal`). + * This is the canonical cancellation channel both before and after startup: + * a provider rejects `start()` after cleaning partial resources when it + * fires before publication, and cancels a published child when it fires + * afterward. + */ readonly signal: AbortSignal + /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions + /** + * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; + * a successful child returns the matching value as {@link SubagentResult.structured}. + */ readonly outputSchema?: StructuredOutputSchema + /** + * Optional absolute delegation-depth cap for the child being started: its + * computed depth must be less than or equal to this non-negative safe + * integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at + * start otherwise. + */ readonly maxDepth?: number + /** + * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; + * rejected at start otherwise. In-process backends apply it as a scoped + * `tools.restrict()` in the child's creation window: the named tools vanish + * from the child's prompt AND refuse to execute (one visibility), with loud + * unknown-name validation. + */ readonly toolFilter?: ToolRestriction + /** + * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; + * rejected at start otherwise. In-process backends register it as a scoped + * `deployment:persona` section on the child, SHADOWING the deployment's + * persona for this child alone — same template semantics as the deployment + * persona (strict `{{…}}` interpolation against the registered variables). + */ readonly persona?: string } ``` -`signal` 是就绪前后唯一的取消通道。[subagent 组合控制 RFC](../rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责 persona、运行时全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 +`signal` 是就绪前后唯一的取消通道。[subagent 组合控制 Agent Note](../../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)规定 persona、live 全局工具过滤、绝对深度以及「可见性而非权限」的设计理由。 ## 终态结果:`SubagentResult` 一次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 ```ts type-equiv +/** + * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. + */ interface SubagentResult { + /** The child's final assistant output (the last assistant message's content). */ readonly output: ContentBlock[] + /** + * The structured result after a requested `outputSchema` was successfully + * satisfied. Requesting a schema does not guarantee presence: a provider can + * end with `stopReason: 'error'` when the child fails or finishes without a + * valid capture. Shape is validated against the request schema by the + * provider; `unknown` here because the seam is schema-agnostic. + */ readonly structured?: unknown + /** Why the run ended. A non-`completed` reason means `output` may be partial. */ readonly stopReason: SubagentStopReason } ``` @@ -55,11 +128,22 @@ interface SubagentResult { `SubagentStopReason` 是一个[可合并扩展的派生联合类型](core.md#the-map--derived-union-pattern)——后端可以添加变体,因此消费方应对已知 case 分支处理,将未知的终态原因视为失败: ```ts type-equiv +/** + * Why a subagent run ended. Merge-extensible (a backend may add variants); + * consumers branch on the known cases and fall through `default`. The known + * cases mirror the harness turn-end vocabulary so the tool layer can map a + * non-`completed` result to an `isError` tool result. + */ interface SubagentStopReasonMap { + /** The child finished its turn normally. */ completed: 'completed' + /** The run was cancelled by its request signal or by disposal. */ aborted: 'aborted' + /** The child failed (model error, transport error). */ error: 'error' + /** The child hit its token ceiling before finishing. */ 'max-tokens': 'max-tokens' + /** The child declined the task. */ refusal: 'refusal' } ``` @@ -71,33 +155,94 @@ interface SubagentStopReasonMap { `SubagentRun` 是消费方持有的、指向一个就绪子 agent 的句柄。消费方 await `result` 并始终 dispose(资源释放)该 run,直至其完全停稳。子 agent 失败时以非 completed 的 stop reason resolve;只有不可表示的基础设施故障才会 reject。可选的 `sendMessage` 和 `resume` 方法通过自身的存在来公布运行时能力。 ```ts type-equiv +/** + * Child handle returned only after readiness. Consumers await {@link result} and must always + * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime + * capability discovery; narrow their presence before calling. + */ interface SubagentRun { - readonly id: AgentId + /** + * Parent-scoped run id. For a local run, this MUST equal the published child + * session id, whose `parentSession` records `request.parent.session.id`; a + * remote provider mints an id unique in the parent namespace. + */ + readonly id: SessionId + /** + * The exact published in-process child, or `undefined` for a remote run. + * When present, its id is {@link id}; the provider retains no ownership + * implication beyond the run's ordinary {@link dispose} contract. + */ + readonly localAgent: Agent | undefined + /** + * Resolves with the child's terminal {@link SubagentResult} when the run + * settles. Does NOT reject on a child-level failure — a model/transport + * failure resolves with `stopReason: 'error'` so the consumer maps it to an + * `isError` tool result. Rejects only on an infrastructure fault the seam + * cannot represent as a stop reason. + */ readonly result: Promise<SubagentResult> + /** + * Cancel remaining work, reach child quiescence, and release the run's + * resources (in-process: dispose the owned agent and remove its session; + * ACP: kill and reap the subprocess). Idempotent. + */ dispose(): Promise<void> + /** + * OPTIONAL (steering capability): send additional content to the running + * child between steps. Present only on providers that support live steering. + */ sendMessage?(content: ContentBlock[]): void + /** + * OPTIONAL (resume capability): send a follow-up task to a settled child, + * continuing its session, and return a fresh run for the continuation. + */ resume?(content: ContentBlock[]): Promise<SubagentRun> } ``` +本地 run 必须在 `start()` fulfill 前发布一个普通子 agent/session,将该子 session id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。 + ## 提供方 seam:`SubagentProvider` 每个提供方是一个具名的子 agent 传输层,多个提供方可以共存。服务在 `start()` 之前校验请求的启动时能力。`inheritsParentContext` 仅描述对话种子注入(`fork`:true;`spawn` 和 `acp`:false),使消费方能生成准确的面向模型的措辞,而不暗示继承了工具、服务或权限。 ```ts type-equiv +/** + * A subagent backend: one transport for running a child agent (in-process + * spawn/fork, ACP to another process, …). Implementations register under a + * unique name via {@link SubagentService.registerProvider}; multiple providers + * coexist in one context (unlike the single-implementation bash seam). The + * Providers are trusted same-process implementations; callers treat their + * descriptors and returned values as borrowed immutable data. + */ interface SubagentProvider { + /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ readonly name: string + /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities + /** + * Whether the child sees the parent's completed-turn prefix. This is descriptive, not a + * service-validated start capability: the model-facing tool derives truthful wording from it. + * It says nothing about tool registration, injected services, or authority inheritance. + */ readonly inheritsParentContext: boolean + /** + * Establish a child and return its handle only after publication. The + * service has already validated that every requested start-time capability + * is supported, so an implementation may assume e.g. `request.maxDepth` is + * honorable when present. If setup fails or `request.signal` aborts before + * fulfillment, the provider owns and cleans all partial resources before this + * promise rejects. Ownership transfers to the caller only on fulfillment. + */ start(request: SubagentStartRequest): Promise<SubagentRun> } ``` -`start()` 仅在 run 就绪时 fulfill。服务观察其 result、发出 `subagent/start`,并返回同一个 run;rejection 意味着提供方已自行清理,不发出生命周期配对事件。进程内子 agent 可通过 `ctx.agents` 发现,远程子 agent 则不必如此。`subagent/end` 报告最终输出或基础设施故障。两个事件均为仅观察事件;每个监听器异常都会被独立隔离。 +`start()` 仅在 run 就绪时 fulfill。服务铸造唯一 `runId`,从提供方的确切 `localAgent` 快照 `local`,观察结果,emit `subagent/start`,并返回同一个 run;rejection 意味着提供方已清理,且不会 emit 生命周期事件对。配对的 `subagent/end` 携带相同标识与最终输出或基础设施失败。两个事件都仅用于观察,每个 listener 异常都会被独立隔离。 ## 进程内后端:深度与种子 spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇: -- **委派深度**是一个可合并扩展的 `AgentOptions.subagentDepth` 字段(顶层 agent 为 `0`,子 agent 为 parent + 1)。只有 `undefined` 表示顶层;所有已存储的值必须是非负安全整数。该字段归 seam 所有——循环既不设置也不读取它——因此嵌套 spawn 会校验父级的已存储深度,拒绝超出安全整数域的派生子深度,并在定义了绝对 `request.maxDepth` 上限时将其施加于子 agent。 +- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,resume 无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 - **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `resume` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml index f2918af35f..8e68fc099b 100644 --- a/docs/core-data-structures/system-prompt.i18n.yaml +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -system-prompt.md: 175f2af407e5c39a24f0f8f8e4e664063b897ead -system-prompt.zh.md: 8827ac0b915915f716643e0a9e04edaacedbc868 +system-prompt.md: 63a750c74300b4353f132d3dae9da52a10631f23 +system-prompt.zh.md: 748e9e4406fe8ca1071aa87f5926bd043378ccdb diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md index 8827ac0b91..748e9e4406 100644 --- a/docs/core-data-structures/system-prompt.zh.md +++ b/docs/core-data-structures/system-prompt.zh.md @@ -8,11 +8,18 @@ ## 组装上下文 -`AssembleContext` 标识一次组装所解析的作用域层。它可通过合并扩展:`dsh-agent` 添加可选的活跃 `agent` 字段,`assembleContextFor(agent)` 同时设置该字段与 `scope`。 +`AssembleContext` 标识一次组装所解析的作用域 layer,并可携带该请求的显式控制 signal。它可合并扩展:`dsh-agent` 添加可选的 live `agent` 字段,`assembleContextFor(agent, signal)` 则一起设置这些显式字段。裸组装既没有 scope,也没有 signal。 ```ts type-equiv +/** Merge-extensible context for one prompt assembly. */ interface AssembleContext { + /** + * Scope whose providers and waterfall listeners participate. When absent, + * only global providers and subject-less listeners participate. + */ scope?: ScopeKey + /** Explicit control signal for the turn that requested this assembly, when any. */ + signal?: AbortSignal } ``` @@ -21,8 +28,11 @@ interface AssembleContext { `ToolProviderResult.schemas` 是当前组装中对模型可见的工具集合。`knownNames` 是提供方在限制前的名称全集,用于区分「配置名拼写错误」与「已知工具在此作用域中被有意隐藏」。 ```ts type-equiv +/** Tool schemas visible in one assembly and their pre-restriction name set. */ interface ToolProviderResult { + /** The schemas this provider contributes to THIS assembly. */ readonly schemas: readonly ToolSchema[] + /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */ readonly knownNames?: readonly string[] } ``` @@ -32,9 +42,21 @@ interface ToolProviderResult { `PromptSection` 是一份只读的同进程注册契约。其文本可以是静态的,也可以从当前组装上下文动态解析。 ```ts type-equiv +/** One contributed section of the system prompt (registry input). */ interface PromptSection { + /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ readonly name: string + /** + * Sections are concatenated in ascending order. Convention: `-100` is the + * harness identity, `0` the deployment persona, tool guidance uses 100–199; + * other negative orders also render before the persona. + */ readonly order: number + /** + * Static text or a provider evaluated at each assembly with that assembly's + * {@link AssembleContext}. The text may reference `{{variable}}`s — they are + * interpolated later, by {@link renderPrompt}. + */ readonly text: string | ((context: AssembleContext) => string) } ``` diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 7f55967dcb..2f09ee1e2d 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -tools.md: f8be67054cd81027d4b751329948a784fa4f0ed9 -tools.zh.md: 27066180bc3a3666e4ef5c789034adc0a82e05b8 +tools.md: ce14a37da33f89b8b90d6d8e70756f94e3d690dd +tools.zh.md: 869480fc38688a15a8681648903e1a4a0959c79d diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 27066180bc..869480fc38 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -8,11 +8,21 @@ ## `ToolDefinition` — 一个已注册的工具 -一个 `ToolSchema`(面向模型的字段)加上 `execute` 函数和可选的 UI 展示器。注册表持有这些定义;agent loop(智能体循环)通过它们分派调用。注册表的 `schemas()` 通过显式白名单构建面向模型的 `ToolSchema[]`——`execute`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。 +由一个 `ToolSchema`(面向模型的字段)、`execute` 函数、仅供宿主使用的调度器元数据和可选 UI 展示函数组成。注册表持有这些定义,循环通过它们分派调用。注册表的 `schemas()` 通过显式允许列表构建面向模型的 `ToolSchema[]`;`execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。 ```ts type-equiv +/** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> + /** + * Run one accepted call. Async work must observe or forward `exec.signal` and + * settle only after its owned work reaches quiescence. The registry preserves + * caller cancellation through around-dispatch signal replacement and does + * not abandon this promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns model-facing content plus optional private presentation metadata. + */ + execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -21,6 +31,20 @@ interface ToolDefinition extends ToolSchema { * cooperative implementation that can reach quiescence when the signal aborts. */ timeoutMs?: number + /** + * Pure synchronous classifier for overlap with sibling tool calls. Only + * `true` opts in; omission, exceptions, non-`true` returns, and invalid + * `defineTool` arguments are exclusive. This metadata is never model-visible. + * + * Opted-in executions must not mutate parent-owned state. Shared state must + * tolerate concurrent dispatch; recorder races are permitted only when they + * commute or fail closed. See the + * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) + * for the full contract. + * @param args - parsed arguments; `defineTool` validates before calling. + * @returns Whether this call may join a parallel group. + */ + isConcurrencySafe?(args: unknown): boolean /** * Optional: how to present the PENDING state of one call in a UI, derived from * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows @@ -51,6 +75,7 @@ interface ToolDefinition extends ToolSchema { 源码:[`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ```ts type-equiv +/** One schema-spec property entry. */ interface SchemaProp { type: SchemaType /** Per-property required flag (NOT the JSON Schema top-level required array). */ @@ -59,7 +84,10 @@ interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] - /** Default value. */ + /** + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. + */ default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec @@ -69,12 +97,29 @@ interface SchemaProp { ``` ```ts type-equiv +/** + * The author-facing parameter schema: a shallow map of property name to + * {@link SchemaProp}. Required-ness is a per-property boolean (`required: + * true`), not a separate array. + */ type SchemaSpec = Record<string, SchemaProp> ``` `SchemaType` 是原始联合类型 `'string' | 'number' | 'boolean' | 'object' | 'array'`。`InferArgs<S>` 将一个 `SchemaSpec` 映射为 TS 参数类型——`required: true` 的属性成为必选键,其余为真正的可选: ```ts type-equiv +/** + * Infer the TS argument type for a complete {@link SchemaSpec}. + * + * Properties marked `required: true` are required keys; all others are + * genuinely optional keys (`?`), so callers may omit them entirely. + * + * Example: + * ```ts + * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> + * // → { path: string; limit?: number } + * ``` + */ type InferArgs<S extends SchemaSpec> = Simplify< & { [K in RequiredKeys<S>]: InferPropValue<S[K]> } & { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> } @@ -90,54 +135,126 @@ type InferArgs<S extends SchemaSpec> = Simplify< `ToolRestriction` 仅作用于实时的部署全局工具层。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域本地工具。仅 deny 的过滤器允许后续未列出的全局工具通过,而 allow 列表则排除它们。 ```ts type-equiv +/** + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. + */ interface ToolRestriction { + /** Global tool names that stay visible; everything else is removed. */ readonly allow?: readonly string[] + /** Global tool names removed from visibility. */ readonly deny?: readonly string[] } ``` ## 执行:可扩展的 waterfall(瀑布式事件)加单调策略 -`ctx.tools.execute()` 接收调用方拥有的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性物化为流水线拥有的 `ToolExecution`,然后依次通过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard → `tools/execute`(around-dispatch 包装层)→ `tools/post-execute`(检查/替换结果)→ `tools/result`(不可变的权威结果)。最终产出为 `ToolExecutionResult`。 +`ctx.tools.execute()` 接受由调用方拥有且包含必需 readonly `signal` 的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性物化为流水线拥有的 `ToolExecution`,然后让调用依次经过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard → `tools/execute`(环绕分派包装层)→ `tools/post-execute`(检查/替换结果)→ `tools/result`(不可变的权威结果)。只有 `tools/execute` 视图可以替换必需的 signal。最终产出为 `ToolExecutionResult`。 ```ts type-equiv +/** Opaque call identity that permits correlation without exposing mutable execution state. */ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ``` ```ts type-equiv +/** + * Caller-supplied description of one tool call. {@link ToolRegistry.execute} + * adds the registry-owned token to form a pipeline {@link ToolExecution}; + * callers do not choose that token. + */ interface ToolExecutionInput { readonly callId: CallId readonly name: string - /** Parsed JSON arguments (unknown — tools validate their own input). */ + /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent /** * Opaque token of the enclosing transport execution, when one exists. Code * Mode sets this on SDK sub-dispatches so commit-style observers can wait for - * the outer `run_code` outcome without receiving its live mutable execution. - */ + * the outer `run_code` outcome without receiving its live mutable execution. + */ readonly parent?: ToolExecutionToken - signal?: AbortSignal + /** Required caller-owned cancellation for this invocation. */ + readonly signal: AbortSignal } ``` +工具函数体接收运行时扩展。`deferContext()` 是组合工具的通道:它记录嵌套分派产生的上下文,而不会在外层调用尚未结束时注入这些上下文。 + ```ts type-equiv +/** + * Runtime context handed to a tool implementation after the registry has + * accepted a {@link ToolExecution}. A composite tool uses + * {@link deferContext} to ferry context produced by nested dispatches back to + * the outer result; the loop appends it only after the outer `tool/result`. + */ +interface ToolRunContext extends ToolExecution { + /** + * Defer one nested-dispatch context until this tool's final result reaches + * the agent loop. Contexts retain their individual source and metadata and + * are emitted in call order. + */ + deferContext(context: HookContext): void +} +``` + +agent loop(智能体循环)向注册表查询每个待处理调用的执行模式,并据此形成独占屏障和滚动池并行执行: + +```ts type-equiv +/** + * Scheduling mode for one pending call. `parallel` may overlap with siblings; + * `exclusive` runs alone and forms an ordering barrier. + */ +type ToolExecutionMode = + | { kind: 'parallel' } + | { kind: 'exclusive' } +``` + +```ts type-equiv +/** + * One pending tool call inside the registry pipeline. Parsed arguments cross + * one lossless-JSON materialization boundary before policy and are deep-frozen; + * call identity, the caller signal, and the registry-assigned {@link token} are + * readonly. The registry freezes the complete object before `tools/result` + * observers run. + */ interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } ``` -`ToolExecutionToken` 是一个不透明的运行时 `Symbol`,仅用于身份比较。在策略执行之前,`execute()` 物化并冻结参数、拒绝非 JSON 输入、分配 token。身份字段和可选的 parent token 保持 readonly;只有 `signal` 可以在分派前后变化。最终观察者接收到的是冻结的执行身份。 +```ts type-equiv +/** + * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper + * may replace the signal for its delegated lifetime, but it cannot remove it. + * The registry fuses every replacement with the captured caller signal. + */ +interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> { + /** Cancellation signal visible to the next wrapper or tool body. */ + signal: AbortSignal +} +``` + +`ToolExecutionToken` 是不透明的运行时 `Symbol`,仅用于身份比较。策略执行前,`execute()` 会物化并冻结参数、拒绝非 JSON 输入并分配 token。身份字段、调用方必需的 signal 和可选的 parent token 均保持 readonly。`ToolDispatchExecution` 包装层可以替换 signal 但不能移除;注册表会在调用工具函数体前重新融合调用方的 signal。最终观察者接收冻结的执行身份。 `ToolGuard` 是感知作用域的最终预分派策略。其形状有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 ```ts type-equiv +/** + * A monotonic execution guard evaluated after every `tools/pre-execute` + * listener and before the tool body. Returning a reason denies the call; + * returning `undefined` leaves it unchanged. Because guards have no allow + * result, listener ordering cannot turn a denial back into permission. + * @param execution - the identity-protected call after extensible pre-execute policy completed. + * @returns a final denial reason, or `undefined` to leave the call allowed. + */ type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined ``` ```ts type-equiv +/** The outcome of one tool call. */ interface ToolExecutionResult { content: ContentBlock[] isError: boolean @@ -148,16 +265,10 @@ interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Extra model-facing context a `tools/post-execute` listener attached for the - * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part - * of this call's `content` — `content`/`feedback` shape the tool RESULT, but - * `additionalContext` is a SEPARATE `context/message`. A step can carry - * multiple tool calls, so the loop BUFFERS every call's `additionalContext` - * and appends them only AFTER all `tool/result`s for the step, keeping - * tool-call/result adjacency intact. Carried on the result purely to ferry it - * from `execute()` up to the loop's per-step buffer. + * Model-facing context for the next request, separate from this tool result. The loop + * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. */ - additionalContext?: HookContext + additionalContexts?: HookContext[] /** * The tool-private presentation payload from a successful `execute` (the object * return form). Threaded onto the `tool/result` session event and back into @@ -175,6 +286,12 @@ interface ToolExecutionResult { 每个拦截 waterfall 返回一个类型化的 **Decision**(与 `agent/*` seam 共享的惯用模式)。`tools/pre-execute` 监听器接收 `(exec, next)` 并返回 `PreToolDecision`;`tools/execute` 包装层返回 `ToolExecutionResult`;`tools/post-execute` 监听器接收 `(exec, result, next)` 并返回 `PostToolDecision`: ```ts type-equiv +/** + * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error; + * `ask` runs only after an approval service returns `allowed-once` and otherwise + * denies. Input rewriting is excluded because arguments are already logged and + * presented. + */ type PreToolDecision = | { kind: 'allow' } | { kind: 'deny'; reason: string } @@ -182,9 +299,13 @@ type PreToolDecision = ``` ```ts type-equiv +/** + * Post-dispatch decision: accept or replace content, attach context for the next + * request, or block by turning corrective feedback into an error result. + */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } - | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } ``` 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 @@ -196,25 +317,41 @@ type PostToolDecision = 调用方用来向 subagent 要求机器可读结果的词汇(`SubagentStartRequest.outputSchema`,见 [subagent.md](subagent.md#the-start-request)),或工作流 `agent()` 调用使用的词汇。它有意不是完整的 JSON Schema:schema 原样传给模型作为强制工具的 `parameters`,产出的值由 `validateStructuredValue` 在客户端校验——因此每个被接受的关键字都必须是校验器实际执行的,`assertSupportedOutputSchema` 会大声拒绝其他任何内容(`OutputSchemaError`,列出所有违规项)。两个遍历器仅推理自有可枚举属性(JSON 不携带其他内容),并拒绝会有损序列化的非纯对象(`Date`、`Map`)。 ```ts type-equiv +/** The scalar values `enum`/`const` may carry (finite numbers only). */ type StructuredScalar = string | number | boolean | null ``` ```ts type-equiv +/** The `type` keywords the subset accepts. */ type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' ``` ```ts type-equiv +/** + * One node of the structured-output schema subset. Recursive via `properties` + * and `items`; see the module doc for the exact keyword semantics. + */ interface StructuredSchemaNode { type: StructuredSchemaType + /** Nested property schemas (`type: 'object'` only). */ properties?: Record<string, StructuredSchemaNode> + /** Required property names; each must appear in `properties`. */ required?: string[] + /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ additionalProperties?: boolean + /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ items?: StructuredSchemaNode + /** Allowed values (scalar types only). */ enum?: StructuredScalar[] + /** The single allowed value (scalar types only). */ const?: StructuredScalar + /** Annotation, ignored for validation. */ description?: string + /** Annotation, ignored for validation. */ title?: string + /** Annotation, ignored for validation (must still be JSON data). */ default?: unknown + /** Annotation, ignored for validation (must still be JSON data). */ examples?: unknown } ``` @@ -222,6 +359,7 @@ interface StructuredSchemaNode { schema 是一个以 object 为根的节点(`enum`/`const` 仅限标量;`description`/`title`/`default`/`examples` 是注解,允许但忽略,但仍要求为 JSON 数据——它们随协议传输): ```ts type-equiv +/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } ``` @@ -232,6 +370,6 @@ type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 - `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,无能力的 UI 获得桥接层从 `output` 派生的围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff——例如文件创建。`tool_call_update` 的内容会替换调用的内容,因此变更工具即使与调用时的片段重复也要返回此卡片,以防结果文本覆盖 diff)。 -`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)为 generic 卡片选择图标。`FileLocation`(`{ path, line? }`)和 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计固定在[渲染意图联合类型 RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) 中;ACP 桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并将文件卡片的标题相对于会话 cwd 做相对化处理。 +`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;ACP 桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并根据会话 cwd 将文件卡片标题转换为相对路径。 -完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。bash 工具自身的 schema(`bash`/`bash_output`/`bash_kill`)及其驱动的执行器见 [bash.md](bash.md)。 +完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。`bash` schema 与执行器见 [bash.md](bash.md);通用后台控制见 [tasks.md](tasks.md)。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index d0c0b33ce5..7a135fdf88 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -user-interaction.md: 47e0e26cd0a5201185dd252a882496456a9c3edd -user-interaction.zh.md: e7d2f27b8729a7694008d1ae7e21bdccae6058dd +user-interaction.md: c7684879c6b81d75e2737857279e68e30626bfa4 +user-interaction.zh.md: e1772fdd9427829027e108b38093c6c04c23f041 diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index e7d2f27b87..e1772fdd94 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -2,7 +2,7 @@ [English](user-interaction.md) | 中文 -[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是提供方无关的词汇,工具或权限插件在需要人类回答后 agent(智能体)才能继续时使用这套词汇。UI 表面提供活跃的 `UserInteractionProvider`:`dsh-stdio-demo` 在 readline 中渲染问题,`dsh-acp` 将其映射为 ACP(Agent Client Protocol)表单征询。 +[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent 才能继续时所使用的、提供方中立的词汇。UI surface 提供活跃的 `UserInteractionProvider`:`dsh-tui` 使用键盘驱动的 overlay,`dsh-acp` 则把问题映射为 ACP 表单 elicitation。 源码:[`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) @@ -11,6 +11,7 @@ `AskUserQuestionOption` 是可选择项的形状。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 ```ts type-equiv +/** One selectable answer offered to the user. */ interface AskUserQuestionOption { /** User-facing label. */ label: string @@ -21,14 +22,17 @@ interface AskUserQuestionOption { ## 问题条目 -`AskUserQuestionItem` 是请求中的一个问题。模型提供一个稳定的 `id`,回答时原样回传,使批量问题可路由。 +`AskUserQuestionItem` 是请求中的一个问题。调用方提供稳定的 `id`,它会随答案原样返回,使批量问题仍可路由。可选的 `detail` 携带辅助文本;提供方会将其随问题渲染,但不会放入可选 option label。 ```ts type-equiv +/** One question in a user-interaction request. */ interface AskUserQuestionItem { - /** Stable model-provided question id, echoed in the answer. */ + /** Stable caller-provided question id, echoed in the answer. */ id: string /** The question to display. */ question: string + /** Optional supporting detail rendered with the question but kept out of option labels. */ + detail?: string /** Optional short heading/group label. */ header?: string /** Optional choices the UI can render as a menu. */ @@ -43,6 +47,7 @@ interface AskUserQuestionItem { `AskUserQuestionRequest` 是跨包(package)的请求。`questions` 是数组,这样 UI 可以在一个流程中呈现相关提示,同时保持每个回答有稳定的 id。 ```ts type-equiv +/** Request for a human answer. */ interface AskUserQuestionRequest { /** Questions to display. */ questions: AskUserQuestionItem[] @@ -58,6 +63,7 @@ interface AskUserQuestionRequest { 提供方为每个已回答的问题 id 返回一条回答。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。 ```ts type-equiv +/** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string @@ -69,6 +75,7 @@ interface AskUserQuestionAnswerItem { ``` ```ts type-equiv +/** The human's answer. */ interface AskUserQuestionAnswer { /** Structured answers keyed by question id. */ answers: AskUserQuestionAnswerItem[] @@ -80,6 +87,7 @@ interface AskUserQuestionAnswer { 同一上下文中只能有一个活跃的提供方。提供方注册绑定到 effect,因此 HMR(热模块替换)或 dispose(资源释放)会移除当前活跃的 UI。 ```ts type-equiv +/** UI-side provider for user questions. */ interface UserInteractionProvider { ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> } @@ -90,6 +98,7 @@ interface UserInteractionProvider { `UserInteractionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会保留 `{ name, code }`,用于面向模型的工具失败,如 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`ASK_ABORTED` 或 ACP 侧取消。 ```ts type-equiv +/** Stable error taxonomy for user-interaction failures. */ class UserInteractionError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml index 05d23f25a4..efcc7b3258 100644 --- a/docs/core-data-structures/web.i18n.yaml +++ b/docs/core-data-structures/web.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -web.md: 74db18835df02ef233f78ad7fbfec5d9b26d58e6 -web.zh.md: c5b99b72bc9ce1be19ebcad5ffad61fa256e1509 +web.md: 20d07240c9d9fea2f1f5abbac810f349a3e81f9b +web.zh.md: d4d9259db5c834349c1cf9c72ed5c74b0a02b32b diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md index c5b99b72bc..d4d9259db5 100644 --- a/docs/core-data-structures/web.zh.md +++ b/docs/core-data-structures/web.zh.md @@ -2,7 +2,7 @@ [English](web.md) | 中文 -Web 访问 seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md),在一个 `ctx.web` 服务上横跨**两项能力**(搜索与抓取),分布在多个包(package)中:接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local)),以及消费方([dsh-tool-web](../../packages/web/tool-web),`web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此,而非 [core.md](core.md)。更换搜索提供方不会改变模型发起查询的方式,更换抓取实现也不会改变模型请求 URL 的方式。 +Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),在同一个 `ctx.web` 服务上横跨**两项能力**(search 与 fetch),并拆分到多个包:接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local))与消费方([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换 search 提供方不会改变模型请求 query 的方式,更换 fetch 实现也不会改变模型请求 URL 的方式。 源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) @@ -15,20 +15,37 @@ Web 访问 seam 是一个[能力 seam](../rfc/implemented/architecture/2026-06-2 面向模型的工具参数仅为一个 `query`;`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。 ```ts type-equiv +/** + * What one search-capable backend can return. The model-facing argument is just + * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged + * and enforced on the way back by the seam (see {@link WebSearchResult}). + */ interface WebSearchRequest { readonly query: string /** * Upper bound on returned sources; the seam truncates to it. Omitted = no - * bound. `dsh-tool-web` always sets it. + * bound. `dsh-tool-web` always sets it. A provider whose API supports a + * result-count control (Exa's `numResults`) should apply it at the request + * layer as a cost/latency optimization; the seam enforces the bound + * regardless. */ readonly maxResults?: number } ``` ```ts type-equiv +/** + * Normalized search outcome. `content` is optional provider-generated answer + * text or summary (Exa returns none; Perplexity returns a generated answer). + * `sources[]` is the portable citation surface. `truncated` is set by the seam + * when it cut `sources[]` down to `maxResults`. + */ interface WebSearchResult { + /** Optional provider-generated answer text, search context, or summary. */ readonly content?: string + /** Citeable sources, already truncated to the request's `maxResults`. */ readonly sources: readonly WebSearchSource[] + /** True when the seam dropped sources to honor `maxResults`. */ readonly truncated: boolean } ``` @@ -36,10 +53,17 @@ interface WebSearchResult { `content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是可移植的引用表面。一个 source 必有 `url`;`title`/`snippet`/`publishedAt` 可选,因为并非每个提供方都返回它们——Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 ```ts type-equiv +/** + * One citeable source. A source always has a URL; `title`, `snippet`, and + * `publishedAt` are optional because not every provider returns them — forcing + * adapters to invent them would make the seam lie (Perplexity citations may be + * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + */ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + /** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */ readonly publishedAt?: string } ``` @@ -47,6 +71,12 @@ interface WebSearchSource { ## 抓取请求与结果 ```ts type-equiv +/** + * What one fetch-capable backend is asked to retrieve. The request deliberately + * omits timeout, format, prompt, and extraction controls: cancellation is a + * direct execution argument, while presentation and higher-level LLM concerns + * belong outside safe retrieval. + */ interface WebFetchRequest { readonly url: string } @@ -55,10 +85,20 @@ interface WebFetchRequest { HTTP 状态码是被抓取资源状态的一部分,不自动视为失败:成功的网络抓取返回 `404`/`500` 时,仍产出一个带状态码和有界解码 body 的 `WebFetchResult`。`url` 是经过允许的重定向后的最终 URL。`WebError` 仅用于无法安全获取或表示资源的情况。 ```ts type-equiv +/** + * Normalized fetch outcome. A successful network fetch of a non-2xx response is + * a result, not an error: the status code is part of the fetched resource + * state. {@link WebError} is reserved for failures to safely retrieve or + * represent the resource. + */ interface WebFetchResult { + /** The final URL after allowed redirects (the request URL is in the request). */ readonly url: string + /** HTTP status code of the fetched response. */ readonly statusCode: number + /** Decoded body, classified by content kind. */ readonly body: WebFetchBody + /** True when the provider capped the decoded body. */ readonly truncated: boolean } ``` @@ -66,6 +106,15 @@ interface WebFetchResult { `WebFetchBody` 是 `dsh-web` 拥有的**封闭**可辨识联合类型(不是可合并扩展的 map):提供方解码 kind,`dsh-tool-web` 渲染它,因此新增一个 kind 是已知包之间的协调变更,而非插件扩展。消费方对 `kind` 做 `switch` 并以 `default: assertNever(...)` 结尾,所以新增 kind 会在每个消费方处编译失败,直到被处理。即使各分支当前字段一致,每个分支仍保持独立的对象字面量,为将来分支特有字段留出空间(例如未来 `pdf` body 的 `pageCount`)。 ```ts type-equiv +/** + * The decoded body of a fetched resource. A CLOSED discriminated union owned by + * `dsh-web`: the provider decodes the kind and `dsh-tool-web` renders it, so a + * new kind is a coordinated change across known packages, not a plugin + * extension. Consumers `switch` on `kind` ending in `default: assertNever(...)` + * so adding a kind breaks compilation at every consumer until handled. Each arm + * stays its own object literal even where fields coincide today, leaving room + * for arm-specific fields later (a `pdf` body's `pageCount`). + */ type WebFetchBody = | { readonly kind: 'html'; readonly content: string } | { readonly kind: 'text'; readonly content: string } diff --git a/docs/core-data-structures/workflow.i18n.yaml b/docs/core-data-structures/workflow.i18n.yaml index 19a2d0db9e..cb0bf7e961 100644 --- a/docs/core-data-structures/workflow.i18n.yaml +++ b/docs/core-data-structures/workflow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -workflow.md: 1571723c172fe851e89550e4ed8588ddb14088a0 -workflow.zh.md: 553d8fa8122b3c9f0cd29840cb2a72d9d5ae6e83 +workflow.md: 8d271b89e71de6f6bef548aa8da61402ef9ada6e +workflow.zh.md: 335f08cefe057bdc0d0f78a90e8301d5e457aae7 diff --git a/docs/core-data-structures/workflow.zh.md b/docs/core-data-structures/workflow.zh.md index 553d8fa812..335f08cefe 100644 --- a/docs/core-data-structures/workflow.zh.md +++ b/docs/core-data-structures/workflow.zh.md @@ -4,20 +4,44 @@ 工作流 seam:一个 agent(智能体)运行由模型编写的编排脚本(SCRIPT),扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 -接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎:每次运行一个 worker,脚本的 vm 上下文在其中执行);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计动机见[动态工作流 RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md)。 +接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm context 位于其中);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) ## 启动请求 -调用方启动一次运行时提交的内容。工具层从模型的 `{ script, meta, args }` 调用加上发起调用的 agent 构建此请求;`meta` 和 `args` 是纯 JSON 数据(引擎在任何代码执行之前对 `meta` 做形状校验,不通过则立即报错:永远不会为了获取 meta 而执行脚本文本)。`parent` 是必填项:脚本 spawn 的每个子 agent 都归属于它(cwd、血统与深度通过 [subagent seam](subagent.md) 传递)。 +调用方启动 run 时提出的请求。普通 workflow 工具根据模型的 `{ script, meta, args }` 调用与发起调用的 agent 构建它;专用消费方还可以为该 run 选择一个引擎级 `subagentProvider` 并调低 `maxTotalAgents`,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据(引擎会对 `meta` 做形状校验,并在任何内容运行前大声拒绝——绝不会通过求值脚本文本来获取它)。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 ```ts type-equiv +/** + * What a caller asks for when starting a workflow run. `meta` and `args` are + * plain JSON DATA by the seam contract (the tool builds both from the model's + * schema-validated call; the engine validates `meta`'s shape and rejects loud + * before anything runs) — an engine never evaluates script text to obtain + * them. `parent` is REQUIRED — every `agent()` the script spawns is + * attributed to it (cwd, lineage, depth flow through the subagent seam). + */ interface WorkflowStartRequest { + /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */ script: string + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ meta: WorkflowMeta + /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown + /** + * Optional engine-wide child-provider override for this run. The workflow + * script cannot observe or replace it; omission uses the engine's configured + * provider. + */ + subagentProvider?: string + /** + * Optional per-run total-child ceiling. Implementations reject values above + * their deployment ceiling before publishing the run. + */ + maxTotalAgents?: number + /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent + /** Cancels the run when aborted (the tool's `exec.signal`). */ signal?: AbortSignal } ``` @@ -27,10 +51,21 @@ interface WorkflowStartRequest { 作为数据附在启动请求上的身份块(工具的 `meta` 参数;字段词汇与 Claude Code 动态工作流的 meta 块一致)。`phases` 仅用于进度展示:`phase()` 调用与标题匹配,供观察者使用;不暗示任何执行结构。 ```ts type-equiv +/** + * The script's identity block, provided as plain JSON data alongside the + * script body (the model-facing tool carries it as its `meta` parameter) and + * validated by the engine before the body runs. `name`/`description` are + * required; the rest is optional annotation. The field vocabulary matches the + * Claude Code dynamic-workflows meta block. + */ interface WorkflowMeta { + /** Short kebab-case workflow name (display + persistence key). */ name: string + /** One-line description of what the workflow does. */ description: string + /** Optional guidance on when this workflow applies (shown in listings). */ whenToUse?: string + /** Optional phase declarations matched by `phase()` calls. */ phases?: WorkflowPhase[] } ``` @@ -40,10 +75,27 @@ interface WorkflowMeta { 一次运行的结果,由 `WorkflowRun.result` resolve。`value` 是脚本的物化返回值——纯宿主域 JSON 数据(脚本无返回值时为 `null`)——仅在 `completed` 时有意义。`stopReason` 是封闭联合类型(引擎所有;消费方可穷举):`completed` | `cancelled` | `error`。非 `completed` 的原因在 `error` 中携带失败信息,消费方将其映射为 `isError` 工具结果,而非把部分输出当作成功上报。 ```ts type-equiv +/** + * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * the script's materialized return value (plain host-realm JSON data; `null` + * when the script returned `undefined`) — meaningful only for `completed`. + * A non-`completed` reason carries the failure in `error`; the consumer maps + * it to an `isError` tool result rather than reporting partial output. + */ interface WorkflowResult { + /** The script's return value (host JSON data; `null` for no return). */ value: unknown + /** Why the run settled. */ stopReason: WorkflowStopReason + /** The failure message (present iff `stopReason` is not `completed`). */ error?: string + /** + * How many `agent()` calls the run accepted over its whole lifetime. On a + * graceful settlement this is the script-side count (calls still queued for + * a concurrency slot included); on a termination path (grace force-settle, + * worker death) it degrades to the host-observed count — calls queued + * inside a terminated script are unknowable then. + */ agentsStarted: number } ``` @@ -53,11 +105,20 @@ interface WorkflowResult { 脚本执行期间消费方持有的句柄。消费方 await `result`,可中途 `cancel`,且必须在每条路径上 `dispose`。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后卡死。`dispose()` = cancel + 有界 settle + 等待子 agent 停稳;它不会因脚本卡死而挂起。 ```ts type-equiv +/** + * Holder-owned live workflow. `result` never rejects and settles within the + * engine's cancellation grace; failures resolve through `stopReason`. Consumers + * may cancel and must call idempotent `dispose()` on every path to await bounded + * script settlement and child quiescence. + */ interface WorkflowRun { readonly id: WorkflowRunId + /** The validated meta block (available before the body runs). */ readonly meta: WorkflowMeta readonly result: Promise<WorkflowResult> + /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ cancel(reason?: string): void + /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ dispose(): Promise<void> } ``` diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index 2affb91142..bc2b7d097a 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -0001-acp-default-export-drops-inject.md: 6a71d8d7ef72e3110a99774b180f3de7115ef622 -0001-acp-default-export-drops-inject.zh.md: d12feca2eb1a35b68e191945562aabe25e043e37 +0001-acp-default-export-drops-inject.md: ab3efc880cb5290dc149b6bacb276ccf581968c1 +0001-acp-default-export-drops-inject.zh.md: caf60dc086e892af4ae3563b0bf073c7e6373602 diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index d12feca2eb..caf60dc086 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -26,7 +26,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 ## 根因 #1——`export default apply` 丢弃了插件的 `inject`(导致 `session/new` 崩溃) -`packages/ui/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出——与仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`stdio-chat` 等)形状相同。但它*还*多了一行其他插件都没有的代码: +`packages/ui/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出——与仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`tui` 等)形状相同。但它*还*多了一行其他插件都没有的代码: ```ts ignore-check export const name = 'acp' diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index 34b22884f8..21f7000078 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -0002-js-expression-disabled-filesystem-tools.md: 43e57a6bd1b68f38c47eeda3c3abb8455024b350 -0002-js-expression-disabled-filesystem-tools.zh.md: ce091a4f6bd19dd9ccb9100583e92589998a1906 +0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 +0002-js-expression-disabled-filesystem-tools.zh.md: d171ce7fe0fea830375f7494be2aac38630c8d6a diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index ce091a4f6b..d171ce7fe0 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -6,7 +6,7 @@ Status: resolved ## 概要 -ACP(Agent Client Protocol)示例试图通过 `disabled: !!js ...` 有条件地启用文件系统插件,但 Cordis 仅在插件 `config` 内部对 JavaScript 表达式求值。原始的表达式对象为 truthy,因此文件系统栈始终处于禁用状态。快照刷新随后将 `UNKNOWN_TOOL` 结果接受为新的 golden 基准。修复方案改用显式的文件系统 overlay,并增加了静态配置守卫和快照结果守卫。 +ACP(Agent Client Protocol)示例试图通过 `disabled: !!js ...` 有条件地启用文件系统插件,但 Cordis 仅在插件 `config` 内部对 JavaScript 表达式求值。原始的表达式对象为 truthy,因此文件系统栈始终处于禁用状态。快照刷新随后将 `UNKNOWN_TOOL` 结果接受为新的预期输出。修复方案改用显式的文件系统 overlay,并增加了静态配置守卫和快照结果守卫。 ## 摘要 @@ -24,7 +24,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader - PR #261 整合了 ACP 组合并刷新了文件系统快照,同时引入了条件式文件系统入口。 - 所有单元测试、覆盖率、快照、文档、构建和 hygiene 检查均通过。 -- 对刷新后的文件系统 golden 的评审发现了通用的失败卡片和结构化的 `UNKNOWN_TOOL` 结果。 +- 对刷新后的文件系统预期输出的评审发现了通用的失败卡片和结构化的 `UNKNOWN_TOOL` 结果。 - 一次真实的 Loader 启动确认:每个 `disabled` 值仍为表达式对象,每个文件系统 fiber 均未创建。 ## 根因 @@ -38,10 +38,10 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader - 文件系统场景启动 `fs.cordis.yml`:一个显式的固定全权限 overlay,配有对应的 replay 配置和独立的 request-header 类。 - [`AGENTS.md`](../../AGENTS.md) 与 [Cordis 入门](../cordis-primer.md#loader-configuration)明确说明 `!!js` 仅在插件 `config` 内有效,条件式组合应使用 overlay。 - `verify-cordis-config` 解析仓库中的 Cordis YAML,拒绝 Loader 入口元数据中的表达式节点(包括 include patch 和插入的入口)。 -- `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,防止其成为被接受的 golden 基准。 +- `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,防止其被提交为预期输出。 ## 教训 - 语法上被接受的配置值不一定在该位置被求值;应记录并验证插值边界。 -- 快照刷新是 fixture 的生产过程,不是正确性审查。诸如已注册工具缺失这类语义上不可能的结果,需要独立于 golden 的断言。 +- 快照刷新是 fixture 的生产过程,不是正确性审查。诸如已注册工具缺失这类语义上不可能的结果,需要独立于预期输出的断言。 - 权限控制只应描述其实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index 1fb6e7d64f..6673537b87 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4dc59e4f5e70f51c4c0baa64fbe34b213f2a7c3d -README.zh.md: b959566d6e2659957d9b6b121303cc4d0e7c4c4e +README.md: df0e2fcb8540aeed005153dbecc451d781ca5ff1 +README.zh.md: 1b099919720867dbc8b6766121bff22d3f221c4c diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index b959566d6e..1b09991972 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -4,7 +4,7 @@ 事故复盘:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 -事故复盘不是 [RFC](../rfc/README.md)(RFC 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露。 +事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露。 当一个 bug 满足以下条件时,请撰写事故复盘:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事故复盘所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2ac67aa892..12b3359f1e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -131,7 +131,11 @@ "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, - { "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeLayer", "source": "packages/core/scope/src/store.ts" }, + { + "doc": "docs/core-data-structures/scope.md", + "symbol": "ScopeLayer", + "source": "packages/core/scope/src/store.ts" + }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", @@ -284,7 +288,11 @@ "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "OutOfBandSessionEventMap", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "OutOfBandSessionEventMap", + "source": "packages/core/session/src/types.ts" + }, { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", @@ -411,18 +419,61 @@ "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, - - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleProviderId", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleModelProvenance", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleSource", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleEventData", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleSnapshot", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleLlmRequestEventData", "source": "packages/session-title/session-title-llm/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleUserMessage", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleAutomaticMode", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleProviderRequest", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleProviderResult", "source": "packages/session-title/session-title/src/index.ts" }, - { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleProvider", "source": "packages/session-title/session-title/src/index.ts" }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleProviderId", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleModelProvenance", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleSource", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleEventData", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleSnapshot", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleLlmRequestEventData", + "source": "packages/session-title/session-title-llm/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleUserMessage", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleAutomaticMode", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleProviderRequest", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleProviderResult", + "source": "packages/session-title/session-title/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-title.md", + "symbol": "SessionTitleProvider", + "source": "packages/session-title/session-title/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", @@ -992,6 +1043,794 @@ "doc": "docs/core-data-structures/lsp.md", "symbol": "LspService", "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "Branded", + "source": "packages/util/brand/src/index.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "AssistantProvenance", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "Message", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "MessageSourceMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "FinishReasonMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "LlmProviderInfo", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "LlmModelInfo", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "LlmModelContext", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "GenerateOptions", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "ToolSchema", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "LlmCallConfig", + "source": "packages/llm/llm/src/call-config.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "AgentCancelCause", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "InjectOptions", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "Agent", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "HookContext", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "PromptDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "ContinuationDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "RequestError", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "RequestErrorDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "ContinuationStop", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.zh.md", + "symbol": "SessionStartSource", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/scope.zh.md", + "symbol": "ScopeKey", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/scope.zh.md", + "symbol": "Scoped", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/scope.zh.md", + "symbol": "Scope", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/scope.zh.md", + "symbol": "ScopeLayer", + "source": "packages/core/scope/src/store.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.zh.md", + "symbol": "AssembleContext", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.zh.md", + "symbol": "PromptSection", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.zh.md", + "symbol": "ToolProviderResult", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.zh.md", + "symbol": "StreamChunk", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.zh.md", + "symbol": "LlmFailure", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.zh.md", + "symbol": "TokenUsage", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.zh.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.zh.md", + "symbol": "AppIdentity", + "source": "packages/llm/llm/src/attribution.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.zh.md", + "symbol": "BlockAssembler", + "source": "packages/llm/llm/src/assembler.ts", + "projection": "public-api" + }, + { + "doc": "docs/core-data-structures/llm-streaming.zh.md", + "symbol": "LlmAdapter", + "source": "packages/llm/llm/src/index.ts", + "projection": "public-api" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SessionEventMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "OutOfBandSessionEventMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "EpochHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "TodoItem", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "TurnTriggerMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "TurnEndReasonMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SurfaceEventType", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SurfaceOp", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SurfaceIntent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SessionSurface", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SurfaceFoldReplacement", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "SurfaceFoldResult", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/session.zh.md", + "symbol": "Session", + "source": "packages/core/session/src/index.ts", + "projection": "public-api" + }, + { + "doc": "docs/core-data-structures/persistence.zh.md", + "symbol": "SessionHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.zh.md", + "symbol": "CreateSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.zh.md", + "symbol": "SessionLocation", + "source": "packages/session-persistence/session-persistence/src/index.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionEventSurface", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionRecord", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionEventRecord", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionLineageNode", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionLineageTrace", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionQueryErrorCode", + "source": "packages/session-query/session-query/src/config.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionEventReadRequest", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionEventWindow", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionEventTraceRequest", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionEventTrace", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolDefinition", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "SchemaProp", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "SchemaSpec", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "InferArgs", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolExecutionToken", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolExecutionInput", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolExecution", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolDispatchExecution", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolExecutionMode", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolRunContext", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolGuard", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolRestriction", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolExecutionResult", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "PreToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "PostToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "StructuredScalar", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "StructuredSchemaType", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "StructuredSchemaNode", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "StructuredOutputSchema", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.zh.md", + "symbol": "AskUserQuestionOption", + "source": "packages/ui/user-interaction/src/types.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.zh.md", + "symbol": "AskUserQuestionItem", + "source": "packages/ui/user-interaction/src/types.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.zh.md", + "symbol": "AskUserQuestionRequest", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.zh.md", + "symbol": "AskUserQuestionAnswerItem", + "source": "packages/ui/user-interaction/src/types.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.zh.md", + "symbol": "AskUserQuestionAnswer", + "source": "packages/ui/user-interaction/src/types.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.zh.md", + "symbol": "UserInteractionProvider", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.zh.md", + "symbol": "UserInteractionError", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.zh.md", + "symbol": "ApprovalRequestId", + "source": "packages/ui/user-approval/src/types.ts" + }, + { + "doc": "docs/core-data-structures/approval.zh.md", + "symbol": "ApprovalOutcome", + "source": "packages/ui/user-approval/src/types.ts" + }, + { + "doc": "docs/core-data-structures/approval.zh.md", + "symbol": "ApprovalPolicy", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.zh.md", + "symbol": "ApprovalRequest", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "DshEnvironmentKey", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "DshEnvironment", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "BashExecRequest", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "BashExecSpec", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "BashRunResult", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "BashSandboxInfo", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "CollectedOutput", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "BashProcess", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.zh.md", + "symbol": "BashProcessRead", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.zh.md", + "symbol": "SandboxMode", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.zh.md", + "symbol": "ConfinedSandboxMode", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.zh.md", + "symbol": "SandboxExecutionPolicy", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.zh.md", + "symbol": "SandboxEnforcement", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.zh.md", + "symbol": "SandboxPolicy", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.zh.md", + "symbol": "SandboxPolicyRequest", + "source": "packages/sandbox/sandbox-policy/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.zh.md", + "symbol": "ConfinedArgv", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.zh.md", + "symbol": "CodeRunRequest", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.zh.md", + "symbol": "CodeRunResult", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.zh.md", + "symbol": "CodeBindingNamespace", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.zh.md", + "symbol": "CodeBindingFunction", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.zh.md", + "symbol": "CodeRunFailure", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsTarget", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsTargetKey", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsVersion", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsInfo", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsPathInfo", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsDirEntry", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsWriteIntent", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsWriteOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsEditRequest", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsEditOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsErrorCode", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FsPolicyExec", + "source": "packages/fs/fs-policy/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.zh.md", + "symbol": "FileReadOutcome", + "source": "packages/fs/tool-fs/src/read-render.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillSource", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillResourceBase", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillSummary", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillCandidate", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillDefinition", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillRegistration", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillLookupOptions", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "SkillProvider", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.zh.md", + "symbol": "Config", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/compaction.zh.md", + "symbol": "CompactionResult", + "source": "packages/compact/compact/src/types.ts" + }, + { + "doc": "docs/core-data-structures/compaction.zh.md", + "symbol": "CompactionTrigger", + "source": "packages/compact/compact/src/index.ts" + }, + { + "doc": "docs/core-data-structures/compaction.zh.md", + "symbol": "PrunedEntry", + "source": "packages/compact/compact-tool-result-prune/src/types.ts" + }, + { + "doc": "docs/core-data-structures/compaction.zh.md", + "symbol": "PruneResult", + "source": "packages/compact/compact-tool-result-prune/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.zh.md", + "symbol": "SubagentCapabilities", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.zh.md", + "symbol": "SubagentStartRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.zh.md", + "symbol": "SubagentResult", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.zh.md", + "symbol": "SubagentStopReasonMap", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.zh.md", + "symbol": "SubagentRun", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.zh.md", + "symbol": "SubagentProvider", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.zh.md", + "symbol": "WebSearchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.zh.md", + "symbol": "WebSearchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.zh.md", + "symbol": "WebSearchSource", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.zh.md", + "symbol": "WebFetchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.zh.md", + "symbol": "WebFetchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.zh.md", + "symbol": "WebFetchBody", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.zh.md", + "symbol": "WorkflowStartRequest", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.zh.md", + "symbol": "WorkflowMeta", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.zh.md", + "symbol": "WorkflowResult", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.zh.md", + "symbol": "WorkflowRun", + "source": "packages/workflow/workflow/src/types.ts" } ] } From c701fad75d0b4387b38e304541a8006df5ed9639 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:02:10 +0800 Subject: [PATCH 015/113] docs(i18n): sync session durability translation --- docs/core-data-structures/session.i18n.yaml | 4 ++-- docs/core-data-structures/session.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 8f2f0b2f8a..c60415915a 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session.md: d3c6ab65df29ef0df504a0a01219e36ebad8e8fd -session.zh.md: 7d5e03f6b8d74e7b2dc0097acb1f5f78f6d3af6e +session.md: d6bdc4bfc523cdeb8036fd32b6b60e90ec51f678 +session.zh.md: ad9ed471185350603976d4baffa9e56bf09c831a diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 7d5e03f6b8..ad9ed47118 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -530,6 +530,6 @@ interface TurnEndReasonMap { ## 持久性契约 -持久化后端依赖的契约如下:持久日志原样保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。 +持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端可选启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。 消费此契约的后端见 [persistence.md](persistence.md)。 From 744d65d63c4f39853b9c29071758d4a1e912b32a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:10:53 +0800 Subject: [PATCH 016/113] docs(i18n): refresh Agent Note translations after merge --- ...6-06-11-content-block-vocabulary.i18n.yaml | 4 +- .../2026-06-11-content-block-vocabulary.zh.md | 10 +- .../2026-06-11-custom-schema-dsl.i18n.yaml | 4 +- .../2026-06-11-custom-schema-dsl.zh.md | 2 +- ...ev-invariants-over-deep-readonly.i18n.yaml | 4 +- ...11-dev-invariants-over-deep-readonly.zh.md | 12 +- ...026-06-11-event-sourced-sessions.i18n.yaml | 4 +- .../2026-06-11-event-sourced-sessions.zh.md | 2 +- ...06-11-microkernel-event-taxonomy.i18n.yaml | 4 +- ...026-06-11-microkernel-event-taxonomy.zh.md | 8 +- ...026-06-11-runtime-arg-validation.i18n.yaml | 4 +- .../2026-06-11-runtime-arg-validation.zh.md | 4 +- ...-06-11-structured-error-taxonomy.i18n.yaml | 4 +- ...2026-06-11-structured-error-taxonomy.zh.md | 8 +- ...-tool-schemas-in-prompt-assembly.i18n.yaml | 4 +- ...6-11-tool-schemas-in-prompt-assembly.zh.md | 2 +- .../2026-06-13-capability-seams.i18n.yaml | 4 +- .../2026-06-13-capability-seams.zh.md | 12 +- .../2026-06-13-twin-llm-adapters.i18n.yaml | 4 +- .../2026-06-13-twin-llm-adapters.zh.md | 4 +- .../2026-06-14-session-persistence.i18n.yaml | 4 +- .../2026-06-14-session-persistence.zh.md | 14 +- ...6-06-15-turn-enclosure-invariant.i18n.yaml | 4 +- .../2026-06-15-turn-enclosure-invariant.zh.md | 6 +- ...06-17-filesystem-capability-seam.i18n.yaml | 4 +- ...026-06-17-filesystem-capability-seam.zh.md | 18 +-- ...nt-lifecycle-and-ownership-seams.i18n.yaml | 4 +- ...-18-agent-lifecycle-and-ownership-seams.md | 2 + ...-agent-lifecycle-and-ownership-seams.zh.md | 26 ++-- .../2026-06-18-session-surface.i18n.yaml | 4 +- .../2026-06-18-session-surface.md | 2 + .../2026-06-18-session-surface.zh.md | 24 ++-- ...ed-persistence-write-coordinator.i18n.yaml | 4 +- ...shared-persistence-write-coordinator.zh.md | 12 +- .../2026-06-20-branded-ids.i18n.yaml | 4 +- .../architecture/2026-06-20-branded-ids.md | 2 + .../architecture/2026-06-20-branded-ids.zh.md | 34 ++--- ...-20-extract-example-app-packages.i18n.yaml | 4 +- ...6-06-20-extract-example-app-packages.zh.md | 22 +-- ...eneric-long-running-tool-runtime.i18n.yaml | 4 +- ...06-20-generic-long-running-tool-runtime.md | 2 + ...20-generic-long-running-tool-runtime.zh.md | 135 ++++++++++++++---- .../2026-06-20-package-hierarchy.i18n.yaml | 4 +- .../2026-06-20-package-hierarchy.md | 2 + .../2026-06-20-package-hierarchy.zh.md | 4 +- ...andatory-app-attribution-headers.i18n.yaml | 4 +- ...21-mandatory-app-attribution-headers.zh.md | 20 +-- .../2026-06-24-web-capability-seam.i18n.yaml | 4 +- .../2026-06-24-web-capability-seam.md | 8 +- .../2026-06-24-web-capability-seam.zh.md | 16 ++- ...06-26-file-context-as-event-gate.i18n.yaml | 4 +- ...026-06-26-file-context-as-event-gate.zh.md | 30 ++-- ...stdin-env-trusted-plugin-surface.i18n.yaml | 4 +- ...ash-stdin-env-trusted-plugin-surface.zh.md | 12 +- ...026-06-30-event-domain-semantics.i18n.yaml | 4 +- .../2026-06-30-event-domain-semantics.zh.md | 10 +- .../2026-07-02-fs-per-session-cwd.i18n.yaml | 4 +- .../2026-07-02-fs-per-session-cwd.md | 2 + .../2026-07-02-fs-per-session-cwd.zh.md | 15 +- ...2-result-time-applied-hunk-diffs.i18n.yaml | 4 +- ...07-02-result-time-applied-hunk-diffs.zh.md | 8 +- ...6-07-02-tool-render-intent-union.i18n.yaml | 4 +- .../2026-07-02-tool-render-intent-union.zh.md | 10 +- ...ilesystem-directory-listing-seam.i18n.yaml | 4 +- ...03-filesystem-directory-listing-seam.zh.md | 2 +- ...bles-and-tool-guidance-ownership.i18n.yaml | 4 +- ...ariables-and-tool-guidance-ownership.zh.md | 24 ++-- ...6-07-05-reconstructable-requests.i18n.yaml | 4 +- .../2026-07-05-reconstructable-requests.zh.md | 29 ++-- ...bagent-provider-lifecycle-events.i18n.yaml | 4 +- ...5-subagent-provider-lifecycle-events.zh.md | 12 +- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-06-timeout-deadline-library.zh.md | 27 +++- ...6-07-07-tool-call-timeout-policy.i18n.yaml | 4 +- .../2026-07-07-tool-call-timeout-policy.zh.md | 18 +-- .../2026-07-08-agent-scope-contexts.i18n.yaml | 4 +- .../2026-07-08-agent-scope-contexts.zh.md | 11 +- ...07-12-agent-scope-runtime-design.i18n.yaml | 4 +- ...026-07-12-agent-scope-runtime-design.zh.md | 24 ++-- ...-06-14-acp-agent-client-protocol.i18n.yaml | 4 +- ...2026-06-14-acp-agent-client-protocol.zh.md | 4 +- .../2026-06-14-acp-multi-session.i18n.yaml | 4 +- .../2026-06-14-acp-multi-session.zh.md | 14 +- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +- .../feature/2026-06-15-code-mode.zh.md | 30 ++-- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 4 +- .../2026-06-17-filesystem-tool-schemas.zh.md | 10 +- ...-acp-terminal-and-tool-rendering.i18n.yaml | 4 +- ...6-18-acp-terminal-and-tool-rendering.zh.md | 6 +- ...06-18-compaction-capability-seam.i18n.yaml | 4 +- .../2026-06-18-compaction-capability-seam.md | 2 + ...026-06-18-compaction-capability-seam.zh.md | 80 ++++++----- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 2 + .../2026-06-21-subagent-capability-seam.zh.md | 17 ++- .../2026-06-22-acp-subagent-backend.i18n.yaml | 4 +- .../2026-06-22-acp-subagent-backend.zh.md | 15 +- .../2026-06-25-ask-user-question.i18n.yaml | 4 +- .../2026-06-25-ask-user-question.zh.md | 10 +- .../2026-06-29-todo-write-tool.i18n.yaml | 4 +- .../feature/2026-06-29-todo-write-tool.zh.md | 6 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.zh.md | 16 +-- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../2026-06-30-hook-protocol-lib.zh.md | 8 +- .../2026-06-30-interception-seams.i18n.yaml | 4 +- .../2026-06-30-interception-seams.zh.md | 22 +-- ...026-06-30-session-store-fork-api.i18n.yaml | 4 +- .../2026-06-30-session-store-fork-api.zh.md | 6 +- ...26-06-30-subagent-observe-enrich.i18n.yaml | 4 +- .../2026-06-30-subagent-observe-enrich.zh.md | 12 +- .../2026-07-05-dynamic-workflows.i18n.yaml | 4 +- .../2026-07-05-dynamic-workflows.zh.md | 8 +- .../feature/2026-07-05-skill-system.i18n.yaml | 4 +- .../feature/2026-07-05-skill-system.zh.md | 6 +- .../2026-07-06-approval-seam.i18n.yaml | 4 +- .../feature/2026-07-06-approval-seam.zh.md | 57 ++++---- .../2026-07-06-explicit-tool-order.i18n.yaml | 4 +- .../2026-07-06-explicit-tool-order.zh.md | 11 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../feature/2026-07-06-sandbox.zh.md | 53 ++++--- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../2026-07-07-mcp-client-plugin.zh.md | 22 +-- .../2026-07-07-session-prefix.i18n.yaml | 4 +- .../feature/2026-07-07-session-prefix.md | 2 + .../feature/2026-07-07-session-prefix.zh.md | 25 ++-- .../2026-07-08-repeat-tool-guard.i18n.yaml | 4 +- .../2026-07-08-repeat-tool-guard.zh.md | 18 +-- ...-self-referential-cordis-toolset.i18n.yaml | 4 +- ...7-08-self-referential-cordis-toolset.zh.md | 18 +-- ...2026-07-10-session-query-service.i18n.yaml | 4 +- .../2026-07-10-session-query-service.zh.md | 15 +- ...nt-persona-tool-filter-and-depth.i18n.yaml | 4 +- ...bagent-persona-tool-filter-and-depth.zh.md | 12 +- .../2026-06-11-doc-sync-enforcement.i18n.yaml | 4 +- .../2026-06-11-doc-sync-enforcement.zh.md | 10 +- .../2026-06-11-quality-gates.i18n.yaml | 4 +- .../process/2026-06-11-quality-gates.md | 2 + .../process/2026-06-11-quality-gates.zh.md | 12 +- .../2026-06-11-tsdown-over-dumble.i18n.yaml | 4 +- .../2026-06-11-tsdown-over-dumble.zh.md | 6 +- ...26-06-11-vendor-cordis-as-source.i18n.yaml | 4 +- .../2026-06-11-vendor-cordis-as-source.zh.md | 2 +- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 4 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 6 +- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.zh.md | 3 +- ...6-06-18-markdown-cross-link-lint.i18n.yaml | 4 +- .../2026-06-18-markdown-cross-link-lint.zh.md | 10 +- ...-06-20-agent-note-classification.i18n.yaml | 4 +- .../2026-06-20-agent-note-classification.md | 2 + ...2026-06-20-agent-note-classification.zh.md | 26 ++-- ...-20-core-data-structures-catalog.i18n.yaml | 4 +- ...6-06-20-core-data-structures-catalog.zh.md | 22 +-- ...6-06-20-generated-cordis-catalog.i18n.yaml | 4 +- .../2026-06-20-generated-cordis-catalog.zh.md | 16 +-- .../2026-07-02-tool-schema-catalog.i18n.yaml | 4 +- .../2026-07-02-tool-schema-catalog.zh.md | 10 +- ...-07-03-documentation-graph-atlas.i18n.yaml | 4 +- ...2026-07-03-documentation-graph-atlas.zh.md | 27 ++-- ...4-cordis-jsdoc-completeness-gate.i18n.yaml | 4 +- ...07-04-cordis-jsdoc-completeness-gate.zh.md | 12 +- ...2026-07-04-doc-tiers-and-budgets.i18n.yaml | 4 +- .../2026-07-04-doc-tiers-and-budgets.zh.md | 10 +- ...26-07-04-persistence-log-catalog.i18n.yaml | 4 +- .../2026-07-04-persistence-log-catalog.zh.md | 14 +- ...-07-05-uniform-agent-note-format.i18n.yaml | 4 +- .../2026-07-05-uniform-agent-note-format.md | 2 + ...2026-07-05-uniform-agent-note-format.zh.md | 22 +-- ...-07-06-export-surface-jsdoc-gate.i18n.yaml | 4 +- ...2026-07-06-export-surface-jsdoc-gate.zh.md | 6 +- ...6-07-06-generated-config-catalog.i18n.yaml | 4 +- .../2026-07-06-generated-config-catalog.zh.md | 6 +- .../2026-07-06-node-engine-floor.i18n.yaml | 4 +- .../2026-07-06-node-engine-floor.zh.md | 6 +- ...6-07-06-parallel-github-ci-gates.i18n.yaml | 4 +- .../2026-07-06-parallel-github-ci-gates.md | 2 + .../2026-07-06-parallel-github-ci-gates.zh.md | 45 +++--- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 4 +- .../2026-07-06-parallel-pre-push-gates.md | 2 + .../2026-07-06-parallel-pre-push-gates.zh.md | 37 ++--- ...10-readme-known-limitations-gate.i18n.yaml | 4 +- ...-07-10-readme-known-limitations-gate.zh.md | 8 +- ...ackage-model-experience-contract.i18n.yaml | 4 +- ...07-12-package-model-experience-contract.md | 2 + ...12-package-model-experience-contract.zh.md | 18 +-- ...-19-drop-mutable-session-summary.i18n.yaml | 4 +- ...6-06-19-drop-mutable-session-summary.zh.md | 8 +- ...llapse-trace-only-session-events.i18n.yaml | 4 +- ...0-collapse-trace-only-session-events.zh.md | 4 +- ...onsumed-llm-adapter-change-event.i18n.yaml | 4 +- ...-unconsumed-llm-adapter-change-event.zh.md | 10 +- ...nconsumed-llm-assembled-surfaces.i18n.yaml | 4 +- ...op-unconsumed-llm-assembled-surfaces.zh.md | 6 +- ...26-06-20-prune-dead-seam-methods.i18n.yaml | 4 +- .../2026-06-20-prune-dead-seam-methods.md | 2 + .../2026-06-20-prune-dead-seam-methods.zh.md | 12 +- ...-06-20-public-agent-stop-surface.i18n.yaml | 4 +- ...2026-06-20-public-agent-stop-surface.zh.md | 12 +- ...ove-agent-boundary-mirror-events.i18n.yaml | 4 +- ...-20-remove-agent-boundary-mirror-events.md | 2 + ...-remove-agent-boundary-mirror-events.zh.md | 31 ++-- ...06-20-unify-agent-and-session-id.i18n.yaml | 4 +- .../2026-06-20-unify-agent-and-session-id.md | 2 + ...026-06-20-unify-agent-and-session-id.zh.md | 38 +++-- .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 4 +- .../2026-06-26-fsspec-style-fs-seam.zh.md | 20 +-- ...07-02-remove-stream-chunk-mirror.i18n.yaml | 4 +- ...026-07-02-remove-stream-chunk-mirror.zh.md | 12 +- ...6-07-04-drop-image-content-block.i18n.yaml | 4 +- .../2026-07-04-drop-image-content-block.zh.md | 6 +- ...6-07-04-drop-inert-request-knobs.i18n.yaml | 4 +- .../2026-07-04-drop-inert-request-knobs.zh.md | 8 +- ...consumed-web-observation-surface.i18n.yaml | 4 +- ...p-unconsumed-web-observation-surface.zh.md | 10 +- .../2026-07-04-fold-stdio-ui-helper.i18n.yaml | 4 +- .../2026-07-04-fold-stdio-ui-helper.md | 2 + .../2026-07-04-fold-stdio-ui-helper.zh.md | 10 +- ...producerless-vocabulary-variants.i18n.yaml | 4 +- ...-prune-producerless-vocabulary-variants.md | 2 + ...une-producerless-vocabulary-variants.zh.md | 14 +- ...7-04-prune-write-only-fs-surface.i18n.yaml | 4 +- ...26-07-04-prune-write-only-fs-surface.zh.md | 6 +- ...-04-remove-agent-steering-mirror.i18n.yaml | 4 +- ...6-07-04-remove-agent-steering-mirror.zh.md | 12 +- ...26-07-04-share-app-bin-boot-glue.i18n.yaml | 4 +- .../2026-07-04-share-app-bin-boot-glue.zh.md | 8 +- ...4-tighten-hook-protocol-contract.i18n.yaml | 4 +- ...07-04-tighten-hook-protocol-contract.zh.md | 8 +- ...m-acp-bridge-unreachable-surface.i18n.yaml | 4 +- ...-trim-acp-bridge-unreachable-surface.zh.md | 4 +- ...unconsumed-skill-provider-events.i18n.yaml | 4 +- ...rop-unconsumed-skill-provider-events.zh.md | 4 +- ...-12-prune-unused-web-seam-fields.i18n.yaml | 4 +- ...6-07-12-prune-unused-web-seam-fields.zh.md | 4 +- ...plify-session-log-representation.i18n.yaml | 4 +- ...-12-simplify-session-log-representation.md | 2 + ...-simplify-session-log-representation.zh.md | 27 ++-- ...026-06-11-property-based-testing.i18n.yaml | 4 +- .../2026-06-11-property-based-testing.zh.md | 6 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +- .../2026-06-19-acp-snapshot-tests.zh.md | 36 ++--- .../2026-06-19-real-api-e2e-ci.i18n.yaml | 4 +- .../testing/2026-06-19-real-api-e2e-ci.zh.md | 12 +- ...ant-snapshot-log-expected-output.i18n.yaml | 4 +- ...-redundant-snapshot-log-expected-output.md | 2 + ...dundant-snapshot-log-expected-output.zh.md | 16 +-- ...-fork-child-replay-seed-boundary.i18n.yaml | 4 +- ...6-22-fork-child-replay-seed-boundary.zh.md | 4 +- ...26-06-22-fork-snapshot-scenarios.i18n.yaml | 4 +- .../2026-06-22-fork-snapshot-scenarios.zh.md | 12 +- ...6-06-22-subagent-snapshot-replay.i18n.yaml | 4 +- .../2026-06-22-subagent-snapshot-replay.zh.md | 10 +- .../2026-07-04-hook-snapshot-matrix.i18n.yaml | 4 +- .../2026-07-04-hook-snapshot-matrix.zh.md | 18 +-- ...-single-source-acp-replay-config.i18n.yaml | 4 +- ...7-04-single-source-acp-replay-config.zh.md | 6 +- ...t-header-content-in-one-scenario.i18n.yaml | 4 +- ...quest-header-content-in-one-scenario.zh.md | 12 +- ...7-08-shared-acp-snapshot-package.i18n.yaml | 4 +- .../2026-07-08-shared-acp-snapshot-package.md | 2 + ...26-07-08-shared-acp-snapshot-package.zh.md | 20 +-- .../2026-06-16-typed-event-schemas.i18n.yaml | 4 +- .../2026-06-16-typed-event-schemas.zh.md | 18 +-- ...026-06-30-pre-tool-input-rewrite.i18n.yaml | 4 +- .../2026-06-30-pre-tool-input-rewrite.zh.md | 4 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...ude-code-and-codex-subagent-backends.zh.md | 28 ++-- ...-07-08-interactive-side-sessions.i18n.yaml | 4 +- ...2026-07-08-interactive-side-sessions.zh.md | 6 +- ...10-sqlite-session-query-provider.i18n.yaml | 4 +- ...-07-10-sqlite-session-query-provider.zh.md | 10 +- ...flow-progress-through-tool-calls.i18n.yaml | 4 +- ...workflow-progress-through-tool-calls.zh.md | 2 +- ...2026-06-11-api-extractor-reports.i18n.yaml | 4 +- .../2026-06-11-api-extractor-reports.md | 2 + .../2026-06-11-api-extractor-reports.zh.md | 4 +- ...-06-11-architectural-conformance.i18n.yaml | 4 +- ...2026-06-11-architectural-conformance.zh.md | 10 +- ...11-supply-chain-and-vendor-drift.i18n.yaml | 4 +- ...-06-11-supply-chain-and-vendor-drift.zh.md | 8 +- ...06-20-discover-package-inventory.i18n.yaml | 4 +- ...026-06-20-discover-package-inventory.zh.md | 13 +- ...04-prune-dead-core-spine-surface.i18n.yaml | 4 +- ...-07-04-prune-dead-core-spine-surface.zh.md | 23 +-- ...deterministic-and-stress-testing.i18n.yaml | 4 +- ...-11-deterministic-and-stress-testing.zh.md | 8 +- .../2026-06-11-mutation-testing.i18n.yaml | 4 +- .../testing/2026-06-11-mutation-testing.zh.md | 10 +- ...-06-11-immutable-public-surfaces.i18n.yaml | 4 +- ...2026-06-11-immutable-public-surfaces.zh.md | 4 +- ...-06-20-providerless-example-base.i18n.yaml | 4 +- ...2026-06-20-providerless-example-base.zh.md | 6 +- ...generate-agent-note-index-tables.i18n.yaml | 4 +- ...-07-04-generate-agent-note-index-tables.md | 2 + ...-04-generate-agent-note-index-tables.zh.md | 34 +++-- ...ssembled-assistant-messages-only.i18n.yaml | 4 +- ...20-assembled-assistant-messages-only.zh.md | 16 +-- ...2026-06-20-drop-acp-session-load.i18n.yaml | 4 +- .../2026-06-20-drop-acp-session-load.zh.md | 10 +- ...026-06-20-drop-acp-terminal-meta.i18n.yaml | 4 +- .../2026-06-20-drop-acp-terminal-meta.zh.md | 8 +- ...-20-drop-bash-output-spill-files.i18n.yaml | 4 +- ...6-06-20-drop-bash-output-spill-files.zh.md | 8 +- ...-20-drop-durable-step-boundaries.i18n.yaml | 4 +- ...6-06-20-drop-durable-step-boundaries.zh.md | 10 +- ...6-20-drop-unused-session-lineage.i18n.yaml | 4 +- ...26-06-20-drop-unused-session-lineage.zh.md | 6 +- ...ld-session-persistence-interface.i18n.yaml | 4 +- ...0-fold-session-persistence-interface.zh.md | 4 +- ...026-06-20-generic-tool-rendering.i18n.yaml | 4 +- .../2026-06-20-generic-tool-rendering.zh.md | 6 +- ...6-06-20-retire-mid-turn-steering.i18n.yaml | 4 +- .../2026-06-20-retire-mid-turn-steering.zh.md | 6 +- ...-06-20-single-session-acp-bridge.i18n.yaml | 4 +- ...2026-06-20-single-session-acp-bridge.zh.md | 8 +- ...06-20-truncate-interrupted-turns.i18n.yaml | 4 +- ...026-06-20-truncate-interrupted-turns.zh.md | 8 +- ...nimplemented-subagent-vocabulary.i18n.yaml | 4 +- ...prune-unimplemented-subagent-vocabulary.md | 2 + ...ne-unimplemented-subagent-vocabulary.zh.md | 18 +-- ...apse-workflow-to-foreground-core.i18n.yaml | 4 +- ...collapse-workflow-to-foreground-core.zh.md | 8 +- ...ne-unused-skill-registry-surface.i18n.yaml | 4 +- ...-prune-unused-skill-registry-surface.zh.md | 8 +- 325 files changed, 1535 insertions(+), 1321 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index fb029f04fe..cc77840694 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-content-block-vocabulary.md: 6a90813ec90c6522a88a09d3536ce0cef8250238 -2026-06-11-content-block-vocabulary.zh.md: c9c65ae7e556cdf4eced0d45051862ff87fcd0fe +2026-06-11-content-block-vocabulary.md: 9aad01cee6083b1f380be66869af3137a07d9f1f +2026-06-11-content-block-vocabulary.zh.md: 123ca87f55be5129855a330efbfe0818d7cdbc0c diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index c9c65ae7e5..123ca87f55 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -1,4 +1,4 @@ -# RFC: 由 dsh-llm 拥有的提供方无关内容块词汇 +# Agent Note: 由 dsh-llm 拥有的提供方无关内容块词汇 Status: implemented @@ -12,7 +12,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。 -会话内上下文注入(`context/message`、`steering/message`)渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 +会话内上下文注入(`context/message`)和轮次中途 steering(`steering/message`)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 ## 曾考虑的替代方案 @@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 ## 后果 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 -- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md)。 -- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。 +- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 +- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 -- 跨包(package)边界的 ID 使用品牌类型(`CallId`、`SessionId`、`AgentId`)——零运行时开销的名义类型。 +- 跨包(package)边界的 ID 使用品牌类型(`CallId`、agent 与 session 共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml index c08b4133de..cf2b48bfdb 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-custom-schema-dsl.md: 34c018b779d45c5eadb7337cb060c43b6f8d09b1 -2026-06-11-custom-schema-dsl.zh.md: 674a6b1a0b67617ffb4ab919899aa108c646a489 +2026-06-11-custom-schema-dsl.md: 9cef4175e4c4a2e6d2ea034d3522d8a8ddd1f5cb +2026-06-11-custom-schema-dsl.zh.md: 8b1241edea4c591e428fbaa15916437a3de42061 diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md index 674a6b1a0b..8b1241edea 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md @@ -1,4 +1,4 @@ -# RFC: 使用自定义类型化 tool-schema DSL 替代 schemastery +# Agent Note: 使用自定义类型化 tool-schema DSL 替代 schemastery Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 2df4576e59..a3029832c5 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-dev-invariants-over-deep-readonly.md: 01a9e45fac77d2513924e78566a44a6059dd9d28 -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 8f8db9f5bb095b8fc9c600b741c16d630a6c8167 +2026-06-11-dev-invariants-over-deep-readonly.md: 8f0e79f15af82ce3125b1f6f767d4ea727aa6d29 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 9998ddd784f956e3497a0bb9d8f5096047f14a70 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 8f8db9f5bb..9998ddd784 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -1,4 +1,4 @@ -# RFC: 源端拥有的会话不可变性与开发模式不变式 +# Agent Note: 源端拥有的会话不可变性与开发模式不变式 Status: implemented @@ -30,11 +30,11 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 `deriveMessages()` 将已记录的表面事件投影为分离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。 -### 不变式插件检查关系 +### 包拥有的不变式配套插件检查关系 -`dsh-invariants` 是一个纯监听器的开发插件。它不冻结记录,没有配置;dispose(资源释放)仅移除其断言。它检查需要跟踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。 +`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要 trace 状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。 -当插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。这使得在轮次中途热重载是安全的,同时不赋予插件对会话存储的所有权。 +当 session 配套插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 ## 曾考虑的替代方案 @@ -55,6 +55,6 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 - `session.events` 暴露稳定的不可变快照,而非私有的增长数组。 - 请求侧的修改无法通过派生消息触及已存储的历史。 -- 开发构建可以启用关系断言而不改变存储行为;dispose 或省略该插件不会削弱日志不可变性。 -- `dsh-invariants` 没有 `Config` 表面,因为它没有可调节的行为。 +- 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。 +- `dsh-invariants` 配置全局启用状态以及包 allow/block regex 列表;每项检查仍由其产品包拥有并测试。 - 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 747bbf3ab0..3ea1c711c4 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-event-sourced-sessions.md: c1460b84c5928a53537a5f6a51f41c4d5b101a12 -2026-06-11-event-sourced-sessions.zh.md: 938404524cd23a5a5b3d6d0b7fd5020df3d35a51 +2026-06-11-event-sourced-sessions.md: 15ba7b23d5eae48e7dee2328b5924493d54aeeb0 +2026-06-11-event-sourced-sessions.zh.md: fc900b0dc43b18ba2016b4dd57584cf15707b016 diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index 938404524c..fc900b0dc4 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -1,4 +1,4 @@ -# RFC: 事件溯源的会话与派生消息历史 +# Agent Note: 事件溯源的会话与派生消息历史 Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml index 107aab3c75..a15ebcfddf 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-microkernel-event-taxonomy.md: 47e363f949b506756d9b20ee7dc53ca81e3f5e4d -2026-06-11-microkernel-event-taxonomy.zh.md: 3b0aa54108bf8737e6fe341c7b0797a554d328ec +2026-06-11-microkernel-event-taxonomy.md: 8bf05b7deba5f054d4ec8ecf104c3b8798e42d4e +2026-06-11-microkernel-event-taxonomy.zh.md: 4ff2ab632ca02e98137a15f19a7996a740a519b0 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md index 3b0aa54108..4ff2ab632c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -1,4 +1,4 @@ -# RFC: 微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环 +# Agent Note: 微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环 Status: implemented @@ -12,8 +12,8 @@ Status: implemented 纯 Cordis 事件分类体系。agent loop(智能体循环)的扩展 seam 是带类型的事件,具有明确的分发模式: -- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决或包装:`agent/prompt-submit`、`agent/request`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 -- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 +- **waterfall(瀑布式事件)**(around-middleware):插件可变换、否决、恢复或包装:`agent/prompt-submit`、`agent/request`、`agent/request-error`、`agent/step-result`、`agent/turn-continuation`、`tools/pre-execute`、`tools/execute`、`tools/post-execute`、`llm/stream`、`system-prompt/assemble`。 +- **serial**(按监听器顺序依次 await;bail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 和 `agent/post-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。 - **parallel**(await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。 - **emit**(同步 fire-and-forget):用于通知:轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。 @@ -25,7 +25,7 @@ Status: implemented ## 后果 -- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。 +- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。 - HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。 - waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。 - 循环必须具备防御性:插件异常在轮次级别被隔离,任何 seam 发出的 steering(中途引导)永远不会被搁置(有回归测试保障)。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index 0fa22e5594..46b8dad11a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-runtime-arg-validation.md: 82d01263225ce719e80631d1890f0a6cc328119d -2026-06-11-runtime-arg-validation.zh.md: 4bd6720f7ec87fcef028ba94f445a5210f1b0d7a +2026-06-11-runtime-arg-validation.md: 6ac2a2ab475c9813dbfbb09e45da71821489d6cd +2026-06-11-runtime-arg-validation.zh.md: 119f738dbb3aaee4786aa58d5495ab5e012e206e diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index 4bd6720f7e..119f738dbb 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -1,4 +1,4 @@ -# RFC: 模型边界处的运行时参数校验 +# Agent Note: 模型边界处的运行时参数校验 Status: implemented @@ -21,4 +21,4 @@ Status: implemented - `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,但不影响读取 `.message` 的调用方。 - 校验开销相对于一次模型调用可忽略不计。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml index 4a381ba271..b886be0e38 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-structured-error-taxonomy.md: 5a5f75038b6124457d2d4d08cbe0bec80415a387 -2026-06-11-structured-error-taxonomy.zh.md: d5a45a447ca00d48cb8366911a10f34c584d915c +2026-06-11-structured-error-taxonomy.md: 9122193b3d01cf5a4c315e6f7a7218153fd4a60a +2026-06-11-structured-error-taxonomy.zh.md: cb29503314edf248c90f2eecd54e01f5b203b99c diff --git a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md index d5a45a447c..cb29503314 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md @@ -1,4 +1,4 @@ -# RFC: 结构化错误分类体系 +# Agent Note: 结构化错误分类体系 Status: implemented @@ -12,7 +12,7 @@ Status: implemented 在 `dsh-llm`(叶子包,所有其他包都已依赖它,不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause` 链接、`name` 默认为子类名。`isHarnessError` 在 seam 处做类型收窄。 -- `LlmError`、`ToolArgsError`(dsh-tools)和 `InvariantError`(dsh-invariants)现在继承该基类,保留各自既有的 code。 +- `LlmError` 和 `ToolArgsError`(dsh-tools)继承该基类,保留各自既有的 code。 - `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存活到日志中,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。 - agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError`(`code: 'UNKNOWN'`,原始值作为 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件此前已暴露 `code`)。 @@ -21,6 +21,6 @@ Status: implemented - 错误端到端可机器路由:插件可以基于 `error.code` 分支,而无需对 message 做子串匹配。 - 一个基类被广泛导入,但它位于所有包已经依赖的包中,代价仅是一条 import 语句,而非新的依赖边。 - `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。 -- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。 +- 参数校验保留其既有的 code 和行为;包自有的诊断不变式独立携带稳定 code,使不变式注册表无需导入产品包。共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml index 57fe989a5d..61b72fe101 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-tool-schemas-in-prompt-assembly.md: 260d56ffab054d874e13eb34eac029bf59b381fc -2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 5ae98dfac676574d00f34580ec079bb6a8f2331f +2026-06-11-tool-schemas-in-prompt-assembly.md: 3643ac3d61be08f629ef0cd0424fef5cb9696c3a +2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 645b829627674e5ccd1507002309b7e8364ac59f diff --git a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md index 5ae98dfac6..645b829627 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md @@ -1,4 +1,4 @@ -# RFC: 工具 schema 是系统提示词组装的一部分 +# Agent Note: 工具 schema 是系统提示词组装的一部分 Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index fcdc63390f..3cdeabed71 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-13-capability-seams.md: f10182737347fc54ff3fd06edb299e569397773d -2026-06-13-capability-seams.zh.md: e65d2f31eb80ce80e92b3e4bbb4d5b8948185c44 +2026-06-13-capability-seams.md: 7c755dced7825d2831acc0901f6412b8e5afe95a +2026-06-13-capability-seams.zh.md: f3aa1ebdee3a16bf095f317b86d01e00b6bbf0c7 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md index e65d2f31eb..f3aa1ebdee 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -1,4 +1,4 @@ -# RFC: 能力 seam——接口/实现/消费方三分 +# Agent Note: 能力 seam——接口/实现/消费方三分 Status: implemented @@ -8,15 +8,15 @@ Status: implemented harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面向什么编程)。将三者捆绑在一个包(package)中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 -这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过 service + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 RFC 决定的是包的边界。 +这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过 service + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 Agent Note 决定的是包的边界。 ## 决策 一项可替换的能力由**三个包**构成: -1. **接口**——一个抽象服务加词汇类型,拥有 `ctx.<key>`,仅依赖 cordis(例如 `dsh-bash`:`BashExecutor`、`BashRunResult`、`BashTask`)。 +1. **接口**——一个抽象服务加词汇类型,拥有 `ctx.<key>`,仅依赖其词汇依赖(例如 `dsh-bash`:`BashExecutor`、`BashRunResult`、`BashProcess`)。 2. **实现**——一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、溢出文件截断)。沙箱化/远程后端是实现同一接口的兄弟包。 -3. **消费方**——模型和插件看到的内容(例如 `dsh-tool-bash`:`bash`/`bash_output`/`bash_kill` 工具 schema)。消费方 `inject` 接口键,从不导入实现类型。 +3. **消费方**——模型和插件看到的内容(例如 `dsh-tool-bash`:`bash` schema,后台句柄注册到通用任务运行时)。消费方 `inject` 接口键,从不导入实现类型。 实现与消费方由此独立演进:沙箱化执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。 @@ -25,8 +25,8 @@ harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化 ## 曾考虑的替代方案 - **单一合并包**:否决。因为它重新耦合了三分设计本要分离的三种变化速率(这正是拆分的意义所在)。 -- **`@cordisjs/plugin-capability`**:这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,不是替换实现的机制。混淆这两个「能力」概念正是本 RFC 所指出的陷阱。 +- **`@cordisjs/plugin-capability`**:这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam)的候选方案,不是替换实现的机制。混淆这两个「能力」概念正是本 Agent Note 所指出的陷阱。 ## 后果 -每项能力需要更多包和更多样板代码(一组 `package.json`/`tsconfig`/README,加上 inject 接线)。换来的是:实现与消费方独立发布和版本管理,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions("Capability seams are three packages")和 [architecture.md](../../../architecture.md) § "Capability seams" 中;bash 三件套是参考模板。何时合并、何时拆分是一个判断问题,架构文档对此有详细说明——本 RFC 记录的是*为什么*默认选择拆分。 +每项能力需要更多包和更多样板代码(一组 `package.json`/`tsconfig`/README,加上 inject 接线)。换来的是:实现与消费方独立发布和版本管理,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions(「Capability seams are three packages」)和 [architecture.md](../../../../docs/architecture.md) §「Capability seams」中;bash 三件套是参考模板。何时合并、何时拆分是一个判断问题,架构文档对此有详细说明——本 Agent Note 记录的是*为什么*默认选择拆分。 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index 545aae3758..f1d2fe1a90 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-13-twin-llm-adapters.md: edd7080e2a16e9f1af2ebd56a4265040dc970c17 -2026-06-13-twin-llm-adapters.zh.md: 2087d498f65326fa0a2cb74be409a926a5e343f5 +2026-06-13-twin-llm-adapters.md: 5c3308b281ce71407002e95dd6e794da2a421fa8 +2026-06-13-twin-llm-adapters.zh.md: 93b084973bccaeb802508e4e939a259c281f2608 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 2087d498f6..93b084973b 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -1,4 +1,4 @@ -# RFC: 以两个 LLM 适配器作为设计验证孪生体 +# Agent Note: 以两个 LLM 适配器作为设计验证孪生体 Status: implemented @@ -24,4 +24,4 @@ Status: implemented ## 后果 -孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 RFC 论证退役其中一个适配器。 +孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index aa7fe88f24..35b81801d0 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-14-session-persistence.md: a50c076746981892239304266194d1e8e309574f -2026-06-14-session-persistence.zh.md: d609e663bd9e36f74bf34404c021a30f54d3bd76 +2026-06-14-session-persistence.md: 1122a52471c6279eff7454cfd31692f05a7bba76 +2026-06-14-session-persistence.zh.md: 0f2bcd4948a3c95cd0497cfa63ef4454630635c7 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index d609e663bd..0f2bcd4948 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -1,4 +1,4 @@ -# RFC: 会话持久化作为基于现有 `SessionEvent` 的抽象服务 +# Agent Note: 会话持久化作为基于现有 `SessionEvent` 的抽象服务 Status: implemented @@ -6,24 +6,24 @@ Status: implemented ## 问题 -会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复("继续昨天的任务")、持久 fork 以及 ACP(Agent Client Protocol)的 `session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都无法实现。 +会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复(「继续昨天的任务」)、持久 fork 以及 ACP(Agent Client Protocol)的 `session/load` 方法([ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md))都无法实现。 -[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行"持久化消息"类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。 +[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。 ## 决策 持久化是一个抽象的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-bash` 模板),而非循环或核心逻辑: 1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 -2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字节保留,**包括 `assistant/chunk`**)。 +2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字节保留,**包括 `assistant/chunk`**),默认编码为[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md),也可通过配置使用原始行。 以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的: - **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,而过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写到 `turn/end` 的事件永不被重写,且循环仅在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并在恢复的 id 上启动一个新 agent(不是 `${agentId}-session`)。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 以明确的错误拒绝。 +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 以明确的错误拒绝。 ## 曾考虑的替代方案 @@ -33,4 +33,4 @@ Status: implemented ## 后果 -新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。 +新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。 diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml index cf8898285a..46cb7aaa20 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-15-turn-enclosure-invariant.md: 26ae1890e481288b3be17f97db14c332f69be4b8 -2026-06-15-turn-enclosure-invariant.zh.md: 1946f829100e39afed826b8cf3ba82d22058c27d +2026-06-15-turn-enclosure-invariant.md: 6e2abd1716f8efc08c06d5ff8faec38282f2a17f +2026-06-15-turn-enclosure-invariant.zh.md: 0921c2574dc171e887664d8a2ea840a4e81f1531 diff --git a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md b/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md index 1946f82910..0921c2574d 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md @@ -1,4 +1,4 @@ -# RFC: 每个会话事件都封闭在一个轮次内 +# Agent Note: 每个会话事件都封闭在一个轮次内 Status: implemented @@ -20,10 +20,10 @@ Status: implemented **每个会话事件都位于一个轮次内部**:在 `turn/start` 与其匹配的 `turn/end` 之间。具体而言: - agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非之前。因此,一旦这些消息被记录,就欠下一个 `turn/end`,既有的 finalizer 保证它被写入。 -- agent **运行中**调用 `agent.inject()` 时,`context/message` 追加到已打开的轮次中(行为不变)。 +- agent **运行中**调用 `agent.inject()` 时,它会加入已打开的轮次。当前步骤执行 assistant 工具调用期间,已接受的上下文按到达顺序等待该批次结算,随后在每个已记录结果之后追加;即使执行中断,也会在轮次关闭前写入。 - agent **空闲时**调用 `agent.inject()`,则将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}` → `context/message` → `turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`。 - agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。 -- `dsh-invariants` 插件在开发环境中**强制执行**该不变式:在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出 `InvariantError`。 +- `dsh-session/invariant` companion 将该检查注册到 `ctx.invariants`:选中后,在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出归因于 `@deepseek-ai/dsh-session` 的 `InvariantError`。 可序列化性不变式在同一源码边界处强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一个位置统一管控,而非由下游碰巧在监听的某个后端各自发现。 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 62ab944e40..8b36d4bdd1 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-filesystem-capability-seam.md: c87c8a44d2e956a9613039378d26f72e8ee97ee7 -2026-06-17-filesystem-capability-seam.zh.md: 4864e77ced5c078fc8c1e970a2ff88c78a37af2a +2026-06-17-filesystem-capability-seam.md: cd8f8572730b1833ed1d7dbe1861d516c2d32925 +2026-06-17-filesystem-capability-seam.zh.md: a7a8d4cbc48c473b1f0669a95c5fbe25eecf94f4 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 4864e77ced..a7a8d4cbc4 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -1,4 +1,4 @@ -# RFC: 文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具 +# Agent Note: 文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具 Status: implemented @@ -20,7 +20,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` ## 决策 -文件系统访问是一个一等的能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): +文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): 1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。 2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。 @@ -28,7 +28,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 -读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 RFC 确立了由三个包构成的 seam;策略从提供方基类拆出的决策由 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。 +读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 Agent Note 确立了由三个包构成的 seam;策略从提供方基类拆出的决策由 [split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [event-gate Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 @@ -36,7 +36,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 -读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。 +读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](2026-06-26-file-context-as-event-gate.md) Agent Note。 ## 包拓扑 @@ -73,7 +73,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更;提供它则在后端的原子临界区内守护变更。 - `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以它从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。 -授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。) +授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 Agent Note 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate Agent Note 将其替换为此处描述的基于新鲜度的策略插件。) 路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。 @@ -83,7 +83,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 -读取和变更结果必须包含不透明的文件 `version`。本地后端可以使用 mtime/size 或类似 hash 的令牌;远程后端可以使用 revision id。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 +读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。 @@ -137,7 +137,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 - **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。 -- **观测状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 +- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 ## 后果 @@ -145,11 +145,11 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` **接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露显示元数据,而不要求消费方理解宿主路径。 -**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 RFC 试图避免的耦合。 +**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 Agent Note 试图避免的耦合。 **编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`。 -**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内部;split-fs-seam RFC 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 +**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 Agent Note 最初将其放在文件系统 seam 内部;split-fs-seam Agent Note 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 **`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测契约不变。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml index 34098ed665..a0ab7d1c83 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-agent-lifecycle-and-ownership-seams.md: c37578020366dd40250c061e34a86d2de6907e19 -2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 1f3cb609da5508d22046f445e08a75dc5a7901cd +2026-06-18-agent-lifecycle-and-ownership-seams.md: 70ebf1c6de97cb14e27377ec1f9bac18fc0766a6 +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 2091182b663dfbd1ac306550de60c1f00d12656f diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 3d84c8074b..70ebf1c6de 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-18-agent-lifecycle-and-ownership-seams.zh.md) + ## Problem Several ACP and tool-bash limitations were symptoms of the same missing seam: plugins could create or resume agents through `ctx.agents`, but they could not own and dispose one agent independently, and long-running bash tasks carried no stable owner in the executor itself. ACP aborted and awaited agents on disconnect but could not unregister just that session's agent; `session/cancel` could not cancel queued-but-not-yet-started work; and `tool-bash` kept task ownership in a plugin-local `Map`, so an HMR reload could make an old task look unowned. diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md index 1f3cb609da..2091182b66 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -1,4 +1,4 @@ -# RFC: Agent 生命周期与所有权 seam +# Agent Note: Agent 生命周期与所有权 seam Status: implemented @@ -12,36 +12,38 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se 三个 seam:队列感知的 cancel、`AgentHandle` 释放器,以及 bash 所有者令牌。 -### 1. 队列感知的 `Agent.cancel(reason?)` +### 1. 队列感知的 `Agent.cancel(cause?)` -`cancel()` 是唯一的公开停止原语。它清除已入队和 steering(中途引导)输入、中止正在进行的步骤,并设置一个在每个轮次边界检查的轮次作用域标记。因此,已入队的 prompt 在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态,ACP 的 `session/cancel` 映射到此方法。对空闲状态的 cancel 不设置标记。 +`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在取得所有权前被取消的 prompt 永不运行,而后来的 prompt 仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条 prompt 搁浅。`whenIdle()` 会到达取消后的静默状态,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)拥有当前的 cause、signal 生命周期与协作式结算契约。 ### 2. `AgentHandle` 异步释放器 -`ctx.agents.create`/`resume` 和 `AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法将其拆除。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享一个 memoize 的拆除过程:停止循环、等待静默与刷写完成、分离 agent 和会话,然后解除其 scope。ID 在注册表条目分离后变为可复用。由配置创建的 agent 归 loop fiber 所有;ACP 存储并 dispose 每个会话的 handle。 +`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(真正的静默,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个会话的释放器,并在断连/拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或 session-store 条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)。 -拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect,因此 LIFO 释放会先停止循环并等待 `agent.done`,然后再分离会话。若使用兄弟 effect,则会并发释放,可能在关闭刷写之前就移除 append 钩子。释放通知被隔离,不会中断拆除链。 +**拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect(`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(`Promise.all`),这会让 session store 的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed` 和 `session/disposed` 通知无法拒绝该链或跳过后续拆除。 ### 3. Bash seam 中的所有者令牌 -后台任务的所有权属于执行器。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output` 和 `bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保存在任务上,使得这道隔离在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。 +后台任务所有权从 `tool-bash` 插件本地的 `Map<string, Agent>` 移入执行器。`BashExecRequest` 新增可选的 `owner?: string`;解析后的 `BashExecSpec` 将其作为必需但可空的 `owner: string | undefined` 携带(被遗忘的 owner 是可见的 `undefined`,而非静默缺失的属性)。执行器把 token 存在任务上,并通过新的 `BashExecutor.ownerOf(id): string | undefined` seam 暴露它(不放在公开的 `BashTask` 上——只有一条读取路径,没有冗余 API)。`tool-bash` 完全删除其 `Map`:它在 `start` 时将 `exec.agent?.id`(共享的注册表/会话 id)盖章为 owner,`bash_output`/`bash_kill` 则以 `!== undefined` 语义把 `ctx.bash.ownerOf(id)` 与调用方 token 比较(空字符串 token 仍是真实 owner)。完成通知通过扫描 `ctx.get('agents')?.list()` 查找 `agent.id === ownerToken` 的存活 agent(经 `ctx.get` 读取——`onTaskDone` 运行在 bash fiber 这一外部 fiber 上,直接使用 `ctx.agents` proxy 会抛异常)。由于所有权现在存活在执行器的任务上(随 `dsh-bash` fiber dispose),它能跨越 `tool-bash` HMR 重载,关闭旧的 `XXX(tool-bash-owner-hmr)` 缺口。(`onTaskDone` 监听器仍受 `tool-bash` 的 `apply` effect 约束,因此落在重载间隙的完成仍会丢失一条通知——既有的重载间隙丢失——但所有权隔离本身已经不受 HMR 影响。) ## 验证 -- ACP 断连或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。 -- 在已入队的 prompt 启动前取消,能阻止该 prompt 运行或吸收下一条 prompt。 -- 重载 `dsh-tool-bash` 不会让另一个会话读取或终止已有的后台任务,因为所有权保留在执行器上。 -- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 演示无需显式管理 handle。 +以下不变式已经成立,并由测试固定: + +- ACP 断连/会话关闭后,不留下该会话的任何已注册 agent 或 session-store 条目,即使 `session/load` 与拆除竞争。 +- 已入队的 prompt 启动前执行 `session/cancel`,能阻止该 prompt 运行;后来接受的 prompt 仍是独立的已入队轮次。 +- `tool-bash` HMR 重载不会使另一个会话能够读取或终止已有的后台任务(所有权保留在执行器上)。 +- 既有的非 ACP 演示无需显式管理 handle 仍能工作;由配置创建的 agent 仍归 `AgentLoop` 插件 fiber 所有。 ## 会话所有者令牌在存活 agent 中唯一 -bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务回滚。`tool-bash` 拥有比较策略;bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。 +bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布会依次进入 session 和 agent;`SessionStore.enter()` 拒绝重复的存活 session id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问*策略*(token 比较)留在消费方 `tool-bash`;bash seam 只存储不透明的 `owner` 字符串且从不解释它——这是正确的接口/实现/消费方拆分。 ## 曾考虑的替代方案 - **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` seam:否决。一条读取路径即可,无需冗余 API。 - **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 -- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 RFC](../simplification/2026-06-20-public-agent-stop-surface.md))。 +- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md))。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml index c319a372fe..578187d1aa 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-session-surface.md: bcad236eaa2bead3c140e7a12772729710d43f83 -2026-06-18-session-surface.zh.md: 2ec60501c183d1bdab22ceb67b7f6aa0040ae82b +2026-06-18-session-surface.md: 80034881d0112076759a68737b5931c8ff659d15 +2026-06-18-session-surface.zh.md: 49303c87aa87569bcc61f215642c9b7f0c408dbf diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md index f1297b1f05..80034881d0 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-18-session-surface.zh.md) + ## Problem The event log is authoritative, but history manipulation had no durable shared mechanism. Plugins such as compaction would otherwise rewrite derived requests through order-sensitive listeners, leave no provenance, and require repeated changes to `deriveMessages()`. diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md index 2ec60501c1..49303c87aa 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 会话 surface——基于事件日志的链表,用于 LLM 消息派生 +# Agent Note: 会话 surface:事件日志上的有序投影 Status: implemented @@ -10,13 +10,13 @@ Status: implemented ## 决策 -新增一个 **surface**:一条派生的、缓存的链表,由「surface 节点」(事件中产出 LLM(大语言模型)消息的子集)组成,通过事件日志中的 `surfaceOp` 标记维护。 +新增一个 **surface**:事件 seq 的派生、缓存有序投影(即产出 LLM(大语言模型)消息的事件子集),通过事件日志中的 `surfaceOp` 标记维护。 ### `SessionEvent` 新增两个顶层字段 每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级): -- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。 +- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。出现的 `[]` 只在 `assistant/message` 上有效,表示已知为空的提供方流;在该事件上省略字段表示旧数据或未记录的溯源。其他 surface 事件一旦出现此字段,就必须是非空列表。溯源是核心设计原则;没有它,replace-range 操作在回放时无法被验证。 - **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。 ### SurfaceOp:两种操作 @@ -27,13 +27,13 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive ``` -1. **Append**:在尾部追加一个新节点。`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时附带 `sourceEventSeqs`(例如 `assistant/message` 记录其 `assistant/chunk` 来源;`tool/result` 记录其 `tool/call` 来源)。 +1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:每个成功的 `assistant/message` 都记录完整的 `assistant/chunk` 来源集合(包括 `[]`),而 `tool/result` 记录其 `tool/call` 来源。 -2. **Replace**:移除从 `start` 到 `end`(两端包含)的节点,并在其位置插入一个新节点。`start` 和 `end` 都必须是当前 surface 上有效的 surface 节点 seq;`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。 +2. **Replace**:移除从 `start` 到 `end`(两端包含)的条目,并在其位置插入新事件的 seq。`start` 和 `end` 都必须存在于当前 surface;`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。 ### SurfaceManager:基于增量,而非全量重建 -`SurfaceManager` 类(`Session` 私有)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**(自上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前的事件不会改变;种子日志只是在首次访问时折叠的初始增量。 +一个 `Session` 拥有一个 `SurfaceManager`,后者维护事件 seq 的有序 `number[]`。管理器会在提交前校验每个种子或追加候选项而不应用它,然后只处理上次同步之后已经提交的事件,而不重新扫描整个日志。`Session.surface` 通过只读的 `SessionSurface` 契约暴露同一个管理器,因此接纳、派生历史、压缩与工作区上下文共享同一份增量状态。Replace 按数组位置找到两端都包含的端点,并把替换 seq splice 到该范围;不会用第二个管理器、链接对象或 seq 到节点的 map 来重复表达顺序。 无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。 @@ -49,23 +49,25 @@ export type SurfaceOp = ### 不变式 -开发模式下的不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq)以及 `surfaceOp`(replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。 +`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:只有 `assistant/message` 可以使用空的溯源列表;引用必须唯一、更早且已知;替换端点必须存在于 surface 顺序中;溯源必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是可选的不变式服务贡献。 每个 surface 可达事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。按照预发布格式策略,无效的种子被拒绝而非升级。 ## 曾考虑的替代方案 - **逐插件的 `agent/request` 包装**(surface 之前的历史操纵模式):监听器排序脆弱、无法持久记录改动内容,且每种新操纵都迫使核心 `deriveMessages()` 再次修改。 -- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。surface 是双向链表,端点自然以节点 seq 命名,单节点替换(`start === end`)在闭区间语义下读起来更自然。 +- **半开区间 `[start, endExclusive)` 的 replace 范围**:否决。端点由 surface 事件 seq 命名,单条目替换(`start === end`)在闭区间语义下读起来更自然。 +- **链接节点对象加 seq map**:否决。生产代码不读取前驱链接,唯一的后继用途就是数组中的下一个位置,而替换本来就需要线性 `indexOf` 查找。单个 seq 数组在保留相同渐进复杂度的同时,只留下一个需要校验的表示。 - **脏标记后全量重建**替代增量处理:在会话生命周期内为 O(N²),每次单事件追加都要重新扫描所有先前事件。 ## 后果 -- **`packages/core/session`**:新增 `surface.ts`(`SurfaceManager`)、新类型(`SurfaceOp`、`SurfaceIntent`)、`SessionEvent` 新字段、修改 `append()`(第三个必选参数 `SurfaceIntent`)、重构 `deriveMessages()`(以 surface 遍历作为唯一派生路径)、surface 感知的 `repair.ts`。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。 +- **`packages/core/session`**:`surface.ts`(`SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent`,`deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。 - **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 - **`packages/session-persistence/session-persistence-sqlite`**:`events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`);`SCHEMA_VERSION` 递增(bump-and-reject,无迁移)。 -- **`packages/support/invariants`**:surface 相关验证规则。 - **`packages/session-persistence/session-persistence-jsonl`**:无需改动。 - **`packages/session-persistence/session-persistence`**:抽象接口不变。 -Surface 是未来历史操纵的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。 +Surface 是未来历史操纵的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。 + +一次 `tool/result` 替换只能改写当前的一个 `tool/result`,并且必须保留除 `content` 以外的每个数据字段。Session 接纳会与位置范围和溯源校验一起强制这条规则,不依赖可选的诊断插件。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index e960781d12..8d572c486f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-shared-persistence-write-coordinator.md: 7648c6f8e43fb8c78f881838aa4d476aba209dca -2026-06-18-shared-persistence-write-coordinator.zh.md: 19f583c2f3d78fcdac1378cfe00e49c0be943eb1 +2026-06-18-shared-persistence-write-coordinator.md: 12800b118bd2fd1189319d0edf37cace3d916a9c +2026-06-18-shared-persistence-write-coordinator.zh.md: 23d6c66989880369c4e26c38145559c198ea1033 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 19f583c2f3..23d6c66989 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -1,4 +1,4 @@ -# RFC: 共享持久化写入协调器 +# Agent Note: 共享持久化写入协调器 Status: implemented @@ -12,7 +12,9 @@ Status: implemented 将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。 -组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 +组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。 + +协调器通过每个存活会话的 `session/disposed` 通知将其退役:等待该 Session 对象自身的初始化,串行执行最后一次排空,随后移除其拥有的状态、缓冲区和初始化条目。排空失败时保留缓冲区,供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会先注销写入路径监听器,再等待所有已准入的退役、剩余缓冲区和链,最后关闭后端。 ### 钩子接口(`PersistenceBackend<TornMarker>`) @@ -28,11 +30,11 @@ Status: implemented ### 不透明的 torn marker -保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 使用要截断到的字节偏移,SQLite 使用要从其开始删除的 seq(两者恰好都是 `number`)。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较折叠在钩子内部,因此返回的 marker 已经是 `number | undefined`;如果不做这层折叠,协调器就必须了解字节长度。 +保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成 closers(它拥有来自 `dsh-session` 的 `interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型:JSONL 携带要截断到的字节偏移,以及从不完整最终帧中解码出的任何完整事件;SQLite 则携带要从其开始删除的 seq。协调器因此既不了解字节长度,也不了解帧恢复状态。 ## 测试 -共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、碰撞、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。各后端自身的测试规格缩减为仅覆盖存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个经由协调器的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture(测试前置数据)钩子),确保协调器的 torn-marker 修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生 torn marker,因此无法触达该分支。 +共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。`runCoordinatorContract`(`tests/coordinator-contract.ts`)覆盖写入路径编排——接管、HMR、碰撞、会话与后端 dispose 排空,以及崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite)为每个后端运行一次。协调器专属测试固定退役 map 清理、同 id 链尾竞态、排空失败后的重试,以及关闭顺序。各后端自身的测试规格只保留存储机制(JSONL:路径安全、fsync 回滚、bucket 列举;SQLite:schema 版本、`scanRows`、事务回滚)。每个真实后端有一个经由协调器的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture(测试前置数据)钩子),确保协调器的 torn-marker 修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生 torn marker,因此无法触达该分支。 ## 曾考虑的替代方案 @@ -41,4 +43,4 @@ Status: implemented ## 后果 -协调器增加了一层间接和一个不透明的 torn marker,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。其钩子面保持窄小:碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。 +协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、保留未提交的缓冲区,并以后端 teardown 为静止状态边界。其钩子面保持窄小:碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index 7d33e94f0a..29c4f4724d 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-branded-ids.md: aab47a1413451edb707ae797e5b99cda6e34efad -2026-06-20-branded-ids.zh.md: 1ccfda3282069510bcdbfbddf4546305fd0923a5 +2026-06-20-branded-ids.md: 93bab1d47c793cc4dd1f1d19fa3af22d1721be29 +2026-06-20-branded-ids.zh.md: f45f39cc3a49f7abf116e77f1ace2db74fc3de67 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md index e7a3110fce..93bab1d47c 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-branded-ids.zh.md) + ## Problem The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index 1ccfda3282..f45f39cc3a 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -1,4 +1,4 @@ -# RFC: 在所有应有之处使用 branded ID +# Agent Note: 在所有应有之处使用 branded ID Status: implemented @@ -6,23 +6,23 @@ Status: implemented ## 问题 -harness 已经为三个标识符做了 brand 处理:`CallId`(`packages/llm/llm/src/brand.ts`)、`SessionId`(`packages/core/session/src/types.ts`)和 `AgentId`(`packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制(由纯类型包(package) `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*"Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。"* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 +harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和共享的 agent/session `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包(package) `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 -**缺口 1:bash seam 中未 brand 的 ID。** `BashTask.id` 以及所有执行器/工具边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型还通过 `task_id` 返回该值,因此混淆 task id 和 session id 既类型正确又可达。 +**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台任务 id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和 session id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 -bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)的 `session.header.id`(`callerToken = (exec) => exec.agent?.session.header.id`,位于 `packages/bash/tool-bash/src/index.ts`),即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是一个跨会话隔离 bug,而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的以 `session.header.id` 作为 owner 的别名缺口("bash owner-token alias hole")。 +bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/bash/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 -**缺口 2:既有 brand 的侵蚀。** `CallId`、`SessionId` 和 `AgentId` 在注册表 map、公开查找参数、ACP 会话跟踪和持久化协调器中退化为裸 string。在查找边界丢弃 brand 会使其主要保护失效。 +**缺口 2:*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括 session store、agent 注册表(二者都以共享的 `SessionId` 为键)、`ToolPresenter` 的 call-id map、ACP 的 session-id 记录和 loading set,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 ## 决策 -纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵循既有的"不是每个 string 都需要"策略。 +纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵循既有的「不是每个 string 都需要」策略。 -- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的工具边界处被 brand)。 +- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的工具边界处被 brand)。 -- **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash seam 从不导入 `dsh-session`。(理由见下一节。) +- **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 共享的 `id`(`SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash seam 从不导入 `dsh-session`。(理由见下一节。) -- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>`、`get(id: SessionId)`、`Map<AgentId, Agent>`、`Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 +- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>`、`Map<SessionId, Agent>`、`get(id: SessionId)`、`Map<CallId, …>`、ACP 的 `SessionId` surface、协调器的 `Map<SessionId, …>`。这是 diff 中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 示意形状(工厂模式与已有的三个 brand 完全一致): @@ -46,24 +46,24 @@ export function OwnerToken(id: string): OwnerToken { ### 为什么不把 `owner` 类型标注为 `SessionId`? -执行器将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保留了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行来自 `SessionId` 的唯一转换。 +显而易见的捷径是直接把 `owner` 类型标注为 `SessionId`——它确实*总是*一个 session id。我们否决这个方案。bash 执行器 seam 是能力 seam(接口 `dsh-bash`、实现 `dsh-bash-local`、消费方 `dsh-tool-bash`),其 owner token 被*明确记录为刻意不透明*:执行器「从不解释它(seam 中没有访问策略——那是消费方的职责)」(`packages/bash/bash/src/types.ts`)。把 seam 字段类型标注为 `SessionId`,会把 `dsh-session` 的词汇引入一个不应知道 owner token *含义*的包——这会让通用执行后端耦合 session 模型,并违背不透明 token 的设计。取代 `dsh-bash-local` 的沙箱或远程执行器不应继承 session 依赖。独立的 `OwnerToken` brand 使 seam 保持解耦:`dsh-bash` 只知道「owner 是某种带 brand 的不透明 token」,而已经决定访问策略的 `dsh-tool-bash` 消费方,是把其 `SessionId` cast 为 `OwnerToken` 的唯一边界。该 brand 仍带来安全收益(不能把 `BashTaskId` 或裸 string 传到 owner 位置),且不引入耦合。 ## 不在范围内 / 可能的扩展 -遵循"不是每个 string 都需要 brand"的策略,刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺: +遵循「不是每个 string 都需要 brand」的策略,刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺: -- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个 brand,仅为控制本 RFC 的影响范围而暂不纳入。 +- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个 brand,仅为控制本 Agent Note 的影响范围而暂不纳入。 - **`ToolName`**(`ToolRegistry` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。 - **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。 - **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。 -- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算"格式错误"?失败时怎么办?),应在独立 RFC 中处理,不应捆绑进这次纯类型变更。 +- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时怎么办?),应在独立 Agent Note 中处理,不应捆绑进这次纯类型变更。 ## 验证 -`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿执行器、本地实现和面向模型的工具,且未添加 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用相应的 brand 而非裸 `string`;来自提供方、ACP 和模型的原始输入通过 brand 工厂进入,而非散落的 cast。 +已落地的不变式:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,并端到端贯穿执行器 seam、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的 surface,且 `dsh-bash` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`CallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP session id、模型提供的 `task_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。 ## 后果 -- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);如果该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 -- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 RFC 不关闭这个缺口(见"不在范围内")——它只阻止这类*类别*错误:传入错误*种类*的 id。 -- **"在哪里停下"仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string"可能被混淆"的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。 +- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及 session-id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 +- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 Agent Note 不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 +- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 Agent Note 倾向于面向模型或用于访问控制的 id。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml index 602102a219..419c158b01 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-extract-example-app-packages.md: b0dd1fe32b2389578d3f3bd3672435923eb1a5d1 -2026-06-20-extract-example-app-packages.zh.md: 9f47c98c9c54357a304490e3d4075fad6cea855a +2026-06-20-extract-example-app-packages.md: b757a0099382a648a4efda4639e275ae6e6a02d1 +2026-06-20-extract-example-app-packages.zh.md: 5814704f0426707ef255a67a7aed9b79cae4aa24 diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md index 9f47c98c9c..5814704f04 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md @@ -1,4 +1,4 @@ -# RFC: 将示例应用提取为独立包 +# Agent Note: 将示例应用提取为独立包 Status: implemented @@ -8,29 +8,28 @@ Status: implemented 示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。 -叶子配置还拥有一个耦合的前门。ACP(Agent Client Protocol)要求 stdout 纯净,并通过 `session/new` 创建 agent;stdio 则需要一个控制台 logger 和一个预创建的 `main`。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复着 Loader 引导和生命周期代码。 +叶子配置还拥有耦合的前门。ACP(Agent Client Protocol)要求 stdout 纯净,并通过 `session/new` 创建 agent;终端应用和 Headless 应用则预创建 `main`,但进程 I/O 契约不同。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复着 Loader 引导和生命周期代码。 ## 决策 每个示例现在**主要是对一个应用包(package)的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**应用包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM(大语言模型)适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。 - **`@deepseek-ai/dsh-agent-spine-demo`**([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含 provider、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。 -- **`@deepseek-ai/dsh-stdio-demo`**([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**([packages/examples/acp-demo](../../../../packages/examples/acp-demo))各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`;ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以添加插件,但安全的组合现在是默认产物。 -- **`start.ts` 已移除。** 每个应用包暴露一个 `bin`(`dsh-stdio-demo` / `dsh-acp-demo`);`demo:*` 脚本调用它(例如 `dsh-stdio-demo ./cordis.yml`)。Loader 引导尾部、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个精简的自执行组合,基于这些辅助函数加上其应用特有的生命周期逻辑(ACP bin:快照模式选择与 stdin-dispose)。`bin.ts` 文件本身仍被排除在覆盖率之外(自执行 CLI(命令行界面)入口,与旧的 `start.ts` 性质相同),由 keyless 的 Loader 路径测试驱动。 -- **每个叶子 `cordis.yml` 精简为**后端 + 配置:LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`)、bash 执行器(`bash-local`)、stdio 演示的 `hmr`(见下方修正),以及一个承载应用配置的 app 条目(模型、系统提示词、持久化根目录——以应用包自身的 `Config` 形式暴露,由它将各值路由到应用接线的目标位置:stdio 路由到预创建的 agent,acp 路由到 bridge 插件)。 -- **echo-agent 折叠到 `dsh-stdio-demo` 上**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。 +- **`@deepseek-ai/dsh-tui-demo`**、**`@deepseek-ai/dsh-cli-demo`** 和 **`@deepseek-ai/dsh-acp-demo`** 各自内置其进程角色。TUI 包含全屏 UI 和预创建的 `main`;Headless 包含 one-shot driver 和预创建的 `main`;ACP 包含 bridge 且不预创建 agent。三者都包含 JSONL 持久化,并省略 stdout logger。 +- **`start.ts` 已移除。** 每个应用包都暴露一个 bin;`demo:*` 脚本调用它。Loader 引导、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));精简的自执行入口由 keyless 的 Loader 路径测试驱动。 +- **每个叶子 `cordis.yml` 精简为**后端、可选产品工具,以及一个承载应用配置的 app 条目。TUI 和 Headless 把模型/会话选择路由到预创建的 agent;ACP 把初始提供方/模型路由到 bridge。 - **`base.yml`、`base-core.yml` 和 `acp-agent/acp-tail.yml` 已退役**——它们共享的主干现在位于 `dsh-agent-spine-demo` 中。 `bash-local` 和 LLM 适配器仍然是**叶子选择**:bundle 提供 `tool-bash`(消费方 schema),叶子选择执行器实现,因此沙箱执行器或回放适配器无需触碰应用即可替换。 ### 实现修正:`hmr` 保留为叶子条目 -提案最初将 `hmr` 列入 stdio 应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包中会在两个方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付: +提案最初将 `hmr` 列入交互式应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到应用包中会在两个方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付: 1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃的 `loader` 服务时会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。 2. 进程内测试层(vitest)甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。 -关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患:ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧,因此将它留在叶子层不会损失耦合论证所关注的安全性。**logger**(真正的耦合点)保持内置:stdio 应用包含它,ACP 应用省略它。 +关键在于,`hmr` 不是 stdout 纯净隐患:ACP 配置中误加该条目不会破坏 JSON-RPC 帧。所有已交付应用都省略 stdout 控制台 logger;stdout 只归应用或协议 driver 所有。 ## 曾考虑的替代方案 @@ -41,13 +40,13 @@ Status: implemented ## 验证 - 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。 -- `demo:echo`、`demo:repl` 和 `demo:acp` 调用应用包的 bin。 -- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。 +- `demo:tui`、`demo:headless` 和 `demo:acp` 调用应用包的 bin。 +- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。 - ACP 回放 transcript(文本记录)保持不变,因为插件集合和加载顺序未改变。 ## 后果 -- **裸插件树的教学性。** echo-agent 内联的 `cordis.yml` 曾一次展示所有插件;主干现在隐藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`。应用包的 README 承担了这份教学职责。 +- **裸插件树的教学性。** 主干现在隐藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`。应用包的 README 承担了这份教学职责。 - **多了一层间接。**「这个演示加载了什么?」从扫描单个 YAML 变成了阅读一个包。 ## 相关 @@ -55,3 +54,4 @@ Status: implemented - 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。 - 基于 [capability-seams](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。 - 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放应用特有的前门)。 +- 后续的[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)拥有最终的 TUI/Headless 拆分,并移除行式与仅 mock 的叶子。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index b98f0fa227..af587f2c25 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-generic-long-running-tool-runtime.md: 9b83a4443d6d654cda75c72fba3369a16be078e2 -2026-06-20-generic-long-running-tool-runtime.zh.md: c1684be4fd5c1c3c9d913e063f4ba66bb6064459 +2026-06-20-generic-long-running-tool-runtime.md: c28236d194ae839f88d900f0fc52707b2c9e53e2 +2026-06-20-generic-long-running-tool-runtime.zh.md: 911c4288fff6e4235b9c88efeaaa882910e559b0 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 4db0d78910..c28236d194 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-generic-long-running-tool-runtime.zh.md) + ## Problem Background bash originally combined two responsibilities: the bash executor ran processes and also managed task ids, ownership, incremental reads, cancellation, completion listeners, and model-facing control tools. Adding background subagents required the same lifecycle and interaction contract. Implementing that contract independently for every long-running capability would duplicate isolation, cleanup, notification, and prompt behavior while teaching the model a different collect-and-stop protocol for each producer. diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index c1684be4fd..911c4288ff 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -1,43 +1,130 @@ -# RFC: 提取通用的长时间运行工具运行时 +# Agent Note: 后台任务运行时(`ctx.tasks`)与通用任务控制工具 -Status: proposed +Status: implemented [English](2026-06-20-generic-long-running-tool-runtime.md) | 中文 ## 问题 -bash 能力 seam 同时支持前台命令和长时间运行的后台任务。后台支持体量不小:抽象执行器暴露 `start`、`get`、`ownerOf`、`list`、`readOutput`、`kill` 和 `onTaskDone`;本地执行器负责跟踪任务、增量读取、owner token、进程清理和完成监听;模型侧看到三个工具(`bash`、`bash_output`、`bash_kill`);工具插件将完成通知注入回所属 agent(智能体)的会话。本地执行器用 owner token 隔离任务访问,因为可预测的全局 task id 会带来跨会话的读取/终止风险。 +后台 bash 原本兼有两项职责:bash 执行器既运行进程,又管理 task id、所有权、增量读取、取消、完成监听器和面向模型的控制工具。新增后台 subagent 需要相同的生命周期与交互契约。如果每种长时间运行能力都独立实现该契约,就会重复隔离、清理、通知和提示词行为,还会让模型为每种生产方学习不同的收集与停止协议。 -[工具实操手册](../../../cookbook/adding-a-tool.md)已经指出了真正的设计异味:后台 bash 本质上是寄居在单个工具内部的通用长时间运行工具基础设施。如果未来的工具也需要后台执行、轮询、终止、所有权和完成通知,这些语义不应藏在 `dsh-bash` 里。 +任务注册表、控制工具与完成通知共同构成一项 harness 功能。bash 和 subagent 只提供执行专属的钩子,不拥有通用任务行为。 -## 提案 +## 决策 -将长时间运行任务的语义从 bash 上移到一个与工具无关的运行时中。bash 仍然能运行后台命令,但不再拥有 task id、ownership token、轮询、取消、完成通知以及模型侧「读取/终止此任务」命令等通用概念。 +`tasks/` 包组拥有后台任务语义: -该运行时应拥有: +- `@deepseek-ai/dsh-tasks` 将运行中的工作注册为 `ctx.tasks`,并拥有 task id、授权、快照、读取、取消、等待、完成监听器与清理。 +- `@deepseek-ai/dsh-tool-tasks` 暴露 `task_output`、`task_list` 和 `task_kill`,注入完成通知,并提供后台任务的系统提示词指导。 -- 稳定的 task id 和 owner token,按调用方的会话/agent 做键。 -- 注册一个长时间运行任务,附带增量输出的生产者和一个完成 promise。 -- 通用的 read/cancel/list 操作,对所有工具使用相同的跨会话授权规则。 -- 向所属会话注入完成通知。 -- 针对 pending/running/completed 任务状态的展示钩子,bash 只提供命令特有的标签和输出格式化。 +长时间运行工具是生产方。`dsh-tool-bash` 将 `BashProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。执行 seam 保持独立,不依赖会话或任务注册表。 -`dsh-bash` 保留 bash 特有的执行契约:将请求解析为命令规格、运行前台命令,或启动进程并将其流/进程句柄交给通用运行时。`dsh-tool-bash` 保留模型侧的命令工具,但后续操作变为通用的长时间运行工具操作,或者 bash 向其注册的共享工具,而不是专属的 `bash_output`/`bash_kill` 管道。 +`TaskService` 是一个具体的进程内服务。TODO(task-service-backend):当第二个后端明确所需生命周期后,将其公共契约与实现分离;systemd 驱动的运行时是一种可能方案,但本 PR(Pull Request)不臆测其持久性、重连、所有权或观察语义。 -## 当前 seam 消费情况 +## 运行时契约 -当前消费方划分清晰:`dsh-tool-bash` 使用完整的前台/后台 seam,而钩子桥接层只使用前台的 `resolve` 和 `run`(带受信的 `stdin` 和 `env`)。`get` 和 `list` 仅在测试中使用;`BashTask.done` 仅在实现内部用于 dispose(资源释放),生产环境的完成通知使用 `onTaskDone`。提取出的运行时应暴露一个公开的完成机制,保留钩子所需的简单前台路径,并决定后台的 `timeoutMs` 是否属于 `start`。如果它拥有进程 spawn 的职责,还应集中处理目前重复的凭证清洗逻辑。 +字面类型见[任务数据结构目录](../../../../docs/core-data-structures/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。 -## 验收标准 +生产方钩子定义三项职责: -- bash 特有的包(package)不再定义通用的任务注册表、owner-token 授权、轮询、取消或完成通知机制。 -- 一个共享的长时间运行任务服务或工具层拥有这些语义,并被文档化为未来任何具备后台能力的工具的接入路径。 -- bash 后台行为仍可通过共享层使用,测试证明跨会话隔离依然成立。 -- ACP 和快照 fixture(测试前置数据)通过共享任务词汇渲染后台 bash,而非通过 bash 专属的生命周期语义。 -- [工具实操手册](../../../cookbook/adding-a-tool.md)将长时间运行工具指向共享运行时,而不是让每个工具自行发明任务协议。 +- `cancel(reason?)` 同步请求终止,具备幂等性,并且必须使 `done` 完成。 +- `done` 从不拒绝,并且仅在生产方释放任务资源后完成。 +- 可选的 `readOutput()` 返回下一个消费式输出增量。省略该钩子即声明这是最终输出任务,其终止结果来自 `TaskOutcome.output`。 -## 风险 +状态包括 `running`、`stopping`、`completed`、`killed` 和 `failed`。退出码或停止原因等生产方专属信息放在 `detail` 中,注册表不解释这些信息。任务 kind 构成可合并扩展的字符串联合;task id 带品牌,并按 `<kind>-N` 生成,每个 kind 各有一个计数器。 -bash 包失去了对一个已经可用的后台任务实现的本地所有权,实现 PR(Pull Request)可能暂时搅动模型侧的工具名称或 transcript(文本记录)展示。如果最终结果是留下一份后台任务契约,而不是让每个未来的长时间运行工具克隆 bash 的私有协议,这种搅动是值得的。 +运行时为 `done` 附加一个 continuation,记录第一个终止结果、解决等待方,并逐个调用完成监听器,同时隔离每个监听器的错误。首次结果优先的结算在资源销毁期间至关重要:如果 `cancel` 抛出,运行时会强制将记录标为失败,并警告工作可能遗留,而不是永远等待一个可能永不完成的 promise。后续生产方结果不能覆盖该诊断,也不能重复通知。`cancel` 返回后如果最终未使 `done` 完成,仍会阻塞资源销毁,因为运行时无法区分这种情况与缓慢但有效的停止。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +任务注册不是生产方工具 fiber 的 effect。因此,重新加载工具或控制接口插件不会终止由 agent(智能体)和后端拥有的工作。任务服务自身释放时会取消所有实时任务,并等待遵守契约的生产方。 + +## 授权与所有者生命周期 + +task id 在运行时全局可见且可预测,因此注册表会授权每次访问。`get`、`read`、`wait` 和 `kill` 接受调用方 `Agent`;`list` 仅返回该调用方可见的任务。有所有者的任务仅允许对应的确切会话访问。无所有者任务向非 agent 调用方开放,并随任务服务一起终止。 + +快照存储所有者的品牌化 `SessionId` 以供授权,生命周期操作则保留确切的实时 `Agent` 实例。这两种身份用途不同:会话相等性授予访问权,精确对象身份决定清理和完成通知的接收方。复用 agent 或会话 id,不能将旧作用域的清理或通知重定向到替代实例。 + +某个所有者的第一个任务会向 `owner.ctx` 附加一个异步 effect。agent 作用域释放时会取消该所有者的实时任务、等待其终止记录,并移除其快照。该 effect 可跨生产方重载存续,并加入 agent 现有的停稳边界。任务服务保留 effect disposer,使服务重载可以在全局资源销毁后,从仍然存活的 agent 作用域中分离回调。 + +对于遵守契约的生产方,`AgentHandle.dispose()` 只在所属后台工作停止后解决。需要比 agent 存活更久的工作必须以无所有者方式启动;要跨运行时重启存续,则需另行设计持久任务。 + +## 服务接口 + +`TaskService` 提供: + +- `start(spec)`:经过预检的原子注册。 +- `get(id, caller?)` 和 `list(caller?)`:非消费式快照。 +- `read(id, caller?)`:消费式流增量或幂等的最终结果。 +- `kill(id, caller?, reason?)`:取消。 +- `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。 +- `onTaskDone(listener)`:effect 作用域内的观察,具有精确所有者投递和监听器隔离。 +- `attachSurface(name)`:控制接口可用性防线。 + +`wait` 在任务完成时返回终止快照,在等待超时时返回实时快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内的结算无法代表一个什么也未收到的读取方压制完成通知。 + +如果生产方加载时没有任何控制接口,调用方就能启动无法收集或停止的工作。因此,`dsh-tool-tasks` 在其整个生命周期内调用 `attachSurface()`;没有附加接口时,`start()` 会在生产方开始执行前失败。该检查发生在启动时而非插件加载时,因为兄弟插件可能并发激活。自定义的非模型接口可以自行附加,无需让注册表了解工具名称。 + +## 面向模型的控制接口 + +`dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 ACP 卡片: + +- `task_output(task_id, wait?, timeout_ms?)` 读取输出,并始终追加 `[status: ...]`。流式任务只返回上次读取以来的输出;最终输出任务在结算后返回结果。除非指定 `wait: true`,否则读取不会阻塞;等待超时由插件配置提供默认值并限定上限。等待超时会报告仍在运行的状态,不会停止任务。 +- `task_list()` 将调用方可见的任务返回为 `<id> [<kind>] <status> — <label>`,没有任务时返回 `(no background tasks)`。 +- `task_kill(task_id, reason?)` 立即请求取消。可选的已记录原因会转发给生产方。终止任务报告现有状态;生产方的取消操作若抛出,调用便会失败,任务保持运行。 + +流式读取共享一个任务作用域内的消费游标,因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API;共享该游标会让读取方彼此消费对方的输出。 + +系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话注入一条已记录的 `context/message`;它会成为下一个请求的持久上下文,但不会唤醒空闲的 agent。 + +当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。 + +## 生产方显式启用 + +每个生产方通过带默认值的配置,自行决定其 schema 是否暴露 `run_in_background`。`dsh-tool-bash` 和每个 `dsh-tool-subagent` 实例都使用 `enableRunInBackground`,默认值为 true。禁用的实例会省略该参数;由于通用参数校验器允许未声明的键,它还会在执行时拒绝强制传入的后台参数。省略 schema 用于声明能力不可用;执行检查负责强制该约束。 + +`ctx.tasks` 不改写生产方 schema。bundle 只转发其所拥有生产方的配置。如果后台调用在没有附加接口的情况下到达 `start()`,运行时防线会在执行前使其失败。 + +## 生产方集成 + +bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `BashProcess`,提供增量读取、取消、退出事实以及不拒绝的停稳 promise。本地执行器只为自身释放时能终止并等待进程而保留实时句柄。前台调用方继续直接使用 `resolve` 和 `run`。 + +对于后台 bash,`dsh-tool-bash` 将调用方 agent 注册为所有者。其钩子将 `kill()` 映射为取消,将 `done` 映射为 completed 或 killed 的 `TaskOutcome`,并将 `readOutput()` 映射为进程的有界增量输出,以及溢出文件与沙箱通知。通用任务工具拥有 id、状态行、列表、等待和完成通知。 + +对于后台 subagent,`dsh-tool-subagent` 创建由任务拥有的 `AbortController`,并在任务 starter 内启动提供方。无论提供方就绪前后,取消都会中止同一个 signal。`done` 同时等待子运行结果和子运行释放,将已完成输出映射为最终结果,将中止映射为 `killed`,并将其他停止原因或基础设施失败映射为 `failed`。中间子历史保留在子会话中,不通过 `readOutput()` 暴露。 + +## 备选方案 + +### 按功能划分控制工具 + +为 bash 与 subagent 分别提供输出/停止工具,会重复 id、隔离、清理、通知和指导,并增加模型的 schema 与协议负担。统一运行时将执行专属行为保留在生产方中,而无需复制任务生命周期。 + +### 立即抽象任务运行时后端 + +当前 `TaskStart.run()` 契约传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在第二种实现出现前抽取接口,会固化错误的边界。 + +### 由消费方负责授权或清理事件 + +由消费方负责检查,会使每个新接口的隔离实现不一致或遗漏。广播清理事件会迫使每个监听器过滤所有 agent,且不提供注册 disposer。集中授权加一个所有者作用域内的 effect,为每个消费方提供相同防线,以及可等待、可移除的生命周期钩子。 + +### 阻塞输出或单独的等待工具 + +默认阻塞会在后台工作运行时串行化父任务。只等待而不读取会增加一次不返回有用信息的模型调用和 schema。`task_output(wait: true)` 显式表达阻塞,并将其与结果交付合并。 + +等待使用共享的 deadline 原语,而不使用通用工具超时策略。等待超时是一次成功的观察,会返回 `[status: running]`;通用策略会将它替换为超时错误。任务返回 task id 后,没有任何工具调用超时会控制任务生命周期。 + +### 由运行时拥有输出接收端 + +推送式接收端可以集中缓冲,但 bash 已经在执行器 seam 后拥有有界缓冲、截断与溢出文件。拉取格式化增量能够保留这一所有权。拥有存储的持久化后端可能足以支持重新审视生产方接口。 + +### 随机 id、提升或生命周期会话事件 + +授权而非不可猜测性才是访问边界,并且 id 不用于派生文件系统路径;顺序生成的品牌化 id 可保持 transcript(文本记录)易读。将前台任务提升为后台任务需要 SDK 并未规定的用户交互契约。启动、读取和通知已作为工具与上下文事件记录,因此专用任务会话事件会重复面向模型的事实。 + +## 测试 + +单元覆盖固定预检原子性、按 kind 分配的 id、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。 + +## 后果 + +bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本契约。 + +有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方,完成通知不会唤醒空闲 agent;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml index 05549751eb..bc70afb74c 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-package-hierarchy.md: a06e963d118d895cc55a0f5e378bbf12678f3491 -2026-06-20-package-hierarchy.zh.md: a136f15a4197645b06fa0a68564a9e0464af1cba +2026-06-20-package-hierarchy.md: aba5fb56176ecab9dd92c61a72cb91d501a442d8 +2026-06-20-package-hierarchy.zh.md: 304bf1da3631187d09b229fb14beb54d4e9a46cc diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index 85c62b7e17..aba5fb5617 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-package-hierarchy.zh.md) + The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here. ## Problem diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md index a136f15a41..304bf1da36 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -1,9 +1,11 @@ -# RFC: 将包重组为模块化层级结构 +# Agent Note: 将包重组为模块化层级结构 Status: implemented [English](2026-06-20-package-hierarchy.md) | 中文 +后续的 [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) 决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) 随后又彻底移除了该接口。这里拥有的决策仍是统一的二层目录深度。 + ## 问题 `packages/` 原先是扁平的:18 个包(package)全部位于 `packages/<name>/`,从路径上完全看不出一个包属于核心产品 API、可替换的能力 seam、提供方适配器、产品集成,还是示例/测试支撑。包的 README 带着 `FIXME(package-hierarchy)`,`scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来同样基础。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index decd9cec05..32fb4fe9a9 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-21-mandatory-app-attribution-headers.md: 132c827b48693af85fde67cf9d2d71cc84e853c6 -2026-06-21-mandatory-app-attribution-headers.zh.md: 9bba6d912b0b9bb33174c1ebb51a84dfdb47af0f +2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de +2026-06-21-mandatory-app-attribution-headers.zh.md: 724a828ce6a9907bec5615d1b2c3a53f2603e388 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 9bba6d912b..724a828ce6 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -1,4 +1,4 @@ -# RFC: 对提供方请求强制携带 `User-Agent` 归属标识 +# Agent Note: 对提供方请求强制携带 `User-Agent` 归属标识 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 +LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 直接触发因素来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 @@ -26,11 +26,11 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个生产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。 -本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本 RFC 定义的共享 `User-Agent` 归属。 +本 Agent Note **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本 Agent Note 定义的共享 `User-Agent` 归属。 提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值: -- `User-Agent` 的产品 token:`deepseek-harness`(与 RFC 之前的线路值及仓库/组织身份保持连续性) +- `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest 读取,绝不手动复制常量 - 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 @@ -42,10 +42,10 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 |---|---| | 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | | 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档化了等效契约,否则不发送 OpenRouter 特有头部。 | -| OpenRouter 端点 | 目前仅 `User-Agent`。本 RFC 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | -| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 RFC 接受额外头部。不要类比复用 `HTTP-Referer`。 | +| OpenRouter 端点 | 目前仅 `User-Agent`。本 Agent Note 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | +| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 | -端点检测不在本 RFC 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。 +端点检测不在本 Agent Note 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。 ## 验证 @@ -55,19 +55,19 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。 - `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。 - `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。 -- 本 RFC 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 +- 本 Agent Note 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 - 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户的稳定标识符。 - 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现的行为。 ## 曾考虑的替代方案 -**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 RFC 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。 +**现在就实现 OpenRouter 应用归属。** 本 Agent Note 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 Agent Note 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。 **向所有提供方发送 OpenRouter 头部。** 否决。这会把一份自定义的 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险将 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。 **仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求,而非哪个应用在发送流量。它们也不暴露公开的应用标题/类别,无法帮助 OpenRouter 等网关构建应用排名。 -**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态的产品身份,且可安全地在每个请求上发送。 +**终端用户 `user`/`metadata` 字段。** 本 Agent Note 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态的产品身份,且可安全地在每个请求上发送。 **仅配置启用的归属。** 否决。默认关闭的设置正是适配器不断漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index b164c4c0f7..15dc1acece 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-24-web-capability-seam.md: fdb611be9efbf29717e41ffa2db86c55258ebe2e -2026-06-24-web-capability-seam.zh.md: f5db538f0565c4fc5667dcbec0791579246d238d +2026-06-24-web-capability-seam.md: 4f4e821fec9494fe9ea96894267707d9dd202e4d +2026-06-24-web-capability-seam.zh.md: 1d81d06d7ff0202174f4348146117e22ea1de038 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 6d3cf3e8c3..4f4e821fec 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -77,6 +77,8 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end `ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half stays close to `LlmService`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape: ```ts +import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' + interface WebSearchProvider { readonly id: string available(): boolean @@ -208,18 +210,18 @@ The seam request deliberately does not include a per-call timeout, `format`, `pr HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure. ```ts -interface WebFetchRequest { +export interface WebFetchRequest { readonly url: string } -interface WebFetchResult { +export interface WebFetchResult { readonly url: string readonly statusCode: number readonly body: WebFetchBody readonly truncated: boolean } -type WebFetchBody = +export type WebFetchBody = | { readonly kind: 'html'; readonly content: string } | { readonly kind: 'text'; readonly content: string } ``` diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index f5db538f05..1d81d06d7f 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -1,4 +1,4 @@ -# RFC: Web 能力 seam——稳定的工具覆盖多个提供方 +# Agent Note: Web 能力 seam——稳定的工具覆盖多个提供方 Status: implemented @@ -16,7 +16,7 @@ harness 需要面向模型的 web 工具,但不能将模型契约绑定到某 ## 决策 -Web 访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): +Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-local`。 @@ -34,7 +34,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam RFC](../../implemented 这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的契约。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 -该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 RFC](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 +该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 ## 包拓扑 @@ -77,6 +77,8 @@ flowchart LR `ctx.web` 是一个提供方注册表加上一个带提供方选择的执行面。注册表部分与 `LlmService` 保持接近:每种能力类别一个 `Map<id, provider>`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状: ```ts +import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' + interface WebSearchProvider { readonly id: string available(): boolean @@ -208,18 +210,18 @@ seam 请求刻意不包含逐调用超时、`format`、`prompt` 或提供方特 HTTP 状态码是已获取资源状态的一部分,不自动构成工具失败。成功的网络获取一个 `404` 或 `500` 响应会返回带有状态码和有界解码正文(当内容类型受支持时)的 `WebFetchResult`。`WebError` 用于无法安全获取或表示资源的失败:无效或被阻断的 URL、重定向策略违规、超时、abort、响应过大、不支持的内容类型、提供方失败或网络失败。 ```ts -interface WebFetchRequest { +export interface WebFetchRequest { readonly url: string } -interface WebFetchResult { +export interface WebFetchResult { readonly url: string readonly statusCode: number readonly body: WebFetchBody readonly truncated: boolean } -type WebFetchBody = +export type WebFetchBody = | { readonly kind: 'html'; readonly content: string } | { readonly kind: 'text'; readonly content: string } ``` @@ -278,7 +280,7 @@ prompt 引导解释了语义分工——`web_search` 用于发现和获取当前 ## 测试 -每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index 609b27aa6a..8f3276b131 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-26-file-context-as-event-gate.md: ac9a8bcbc5bfda2b35cae4993497273608abf35b -2026-06-26-file-context-as-event-gate.zh.md: eda637f9adf8346779c70d59575863bf7e1162c9 +2026-06-26-file-context-as-event-gate.md: 4700222aa2e0f91d9f355495c228e2eb92825f55 +2026-06-26-file-context-as-event-gate.zh.md: cf490c9f59f1914f1d5d6bdd9fdf4c08711db91e diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index eda637f9ad..cf490c9f59 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -1,4 +1,4 @@ -# RFC: 将 `dsh-fs-policy` 改为事件门控插件,而非方法接口 +# Agent Note: 将 `dsh-fs-policy` 改为事件门控插件,而非方法接口 Status: implemented @@ -6,12 +6,12 @@ Status: implemented ## 问题 -[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 +[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 这把三件本应可分离的事情耦合在了一起: 1. **工具做什么**——解析路径、读取窗口、写入/编辑文件。这是工具的职责,只需要 `ctx.fs`。 -2. **新鲜度/观测策略**——"编辑前必须先读"、"写入/编辑必须基于你读到的版本"。这是 `dsh-fs-policy` 插件的职责。 +2. **新鲜度/观测策略**——「编辑前必须先读」、「写入/编辑必须基于你读到的版本」。这是 `dsh-fs-policy` 插件的职责。 3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。 由于工具调用的是 `fileContext` 方法,移除策略层就是一个破坏性变更,而非优雅地失去一个*附加*能力。策略层对工具的运行是承重性的,而非可选的收紧。 @@ -37,12 +37,12 @@ provider dsh-fs-local local implementation of ctx.fs ## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat -`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑",**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性: +`dsh-fs-policy` 强制执行「你必须基于你读到的版本来写入/编辑」,**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性: -- "你读过这个文件吗?"是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。 -- "你读到的版本是否仍为最新?"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。 +- 「你读过这个文件吗?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。 +- 「你读到的版本是否仍为最新?」由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。 -这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;"必须基于最近一次读取"的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。 +这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;「必须基于最近一次读取」的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。 ## 提供方契约变更:版本守卫变为可选 @@ -62,7 +62,7 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion // { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) ``` -`FsWriteIntent` 联合类型本身不变——第三种"无条件"状态通过*省略* `expected` 来表达,因此两个 mutation 共享同一种对称形状(`expected?`:省略 = 无守卫,传入 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能出现的"无守卫"情况是新增的,且它是裸提供方的默认行为。无论哪种情况,mutation 仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会产生撕裂文件);"无条件"去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失目标报告为 `FS_STALE_VERSION`,保持一个统一的编辑失败码表示"此刻无法编辑该目标"。 +`FsWriteIntent` 联合类型本身不变——第三种「无条件」状态通过*省略* `expected` 来表达,因此两个 mutation 共享同一种对称形状(`expected?`:省略 = 无守卫,传入 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能出现的「无守卫」情况是新增的,且它是裸提供方的默认行为。无论哪种情况,mutation 仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会产生撕裂文件);「无条件」去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失目标报告为 `FS_STALE_VERSION`,保持一个统一的编辑失败码表示「此刻无法编辑该目标」。 ## 事件词汇(由 `dsh-fs` 拥有) @@ -110,7 +110,7 @@ interface Events { ## 工具契约(`dsh-tool-fs`) -工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称"后端"要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。 +工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称「后端」要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。 `dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。 @@ -134,7 +134,7 @@ interface Events { - `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。 - `fs/observed` 监听器:`record(owner, key, version)`。 -一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着"此 owner 在此版本观测过此目标",而非狭义的"已读取过"。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。 +一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着「此 owner 在此版本观测过此目标」,而非狭义的「已读取过」。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。 `dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。 @@ -144,13 +144,13 @@ interface Events { - **read** 行为不变(它从不需要策略;只是 emit 了一个现在无人监听的 `fs/observed`)。 - **write** 无条件 create-or-overwrite:`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无先读要求,无版本检查。 -- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 无版本守卫、无先读要求地匹配并重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。 +- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 无版本守卫、无先读要求地匹配并重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的「此刻无法编辑该目标」错误码一致。 两个 mutation 仍是原子的(后端的 per-target 锁是无条件的)。仅仅是*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、先读后编辑和版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身无需任何变更。 ## 取代关系 -本 RFC 修正——而非推翻——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 +本 Agent Note 修正——而非推翻——[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。split-fs-seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 ## 验证 @@ -158,14 +158,14 @@ interface Events { ## 曾考虑的替代方案 -- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 +- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 - **策略侧版本检查**(`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 - **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。 ## 后果 - **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。 -- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。 -- **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。 +- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它「只是存储」。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。 +- **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 Agent Note(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。 - **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。 - **裸提供方不做先读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index d4d7cb2482..51d0eb9a78 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 712c17532bc1e6b95479aec5b544bc7e296952ec -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: db50f08dae30aa0c43e741a4f27f948f712954e0 +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58 +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index db50f08dae..9486f8c35c 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 在 bash seam 上支持 stdin 与额外 env +# Agent Note: 在 bash seam 上支持 stdin 与额外 env Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 RFC 添加这两个输入。 +钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 Agent Note 添加这两个输入。 -`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。 +`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。 ## 决策 @@ -16,9 +16,9 @@ Status: implemented 三个有意为之的选择: -1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。 +1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使键名与凭证同形。这是正确的,因为擦除的职责很窄:阻止 harness 的*环境* `process.env` 凭证泄漏到被 spawn 的命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非环境中的秘密),因此擦除不构成对它的约束。`childEnv(extra?)` 按 `scrub(process.env)` → `ENV_OVERRIDES`(对模型友好的 `TERM=dumb` 等)→ `extra` 的顺序分层,后者优先。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。 +钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和溢出行为。模型接口面不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/core-data-structures/bash.md)。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 1b064ea80a..b68aef5fd2 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-event-domain-semantics.md: a9d33a7bfb549e1ec2102c23af78f91d9ae4b904 -2026-06-30-event-domain-semantics.zh.md: dc7390e3d2e1cda3469fa77389d0d6ad39b7a713 +2026-06-30-event-domain-semantics.md: 7310840088d4ff77ddf83c5c16753b4d46256692 +2026-06-30-event-domain-semantics.zh.md: b082fdfda36968e1946ba6325f2315a970cd445b diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index dc7390e3d2..b082fdfda3 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -1,4 +1,4 @@ -# RFC: 事件域语义——session 是事实日志,agent 是运行时表面 +# Agent Note: 事件域语义——session 是事实日志,agent 是运行时表面 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: +harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: - `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。 - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 @@ -26,14 +26,14 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 -**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除(它们完全没有消费方);turn 镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 +**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)从 `session/event` 渲染边界,同时保留其实时目标对象用于固定的 `main` 标签。step 镜像先被移除(它们完全没有消费方);turn 镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 ## 后果 - 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。 - 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的 turn 边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 -- 完整实现见[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 +- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 - Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml index 1af8dd8fe3..fb07c3c828 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-fs-per-session-cwd.md: 2513294d6bc991ee39155bbf20869829cca1a188 -2026-07-02-fs-per-session-cwd.zh.md: 2c3957113642fe2bf7b3a4f2b6186246b32aff3f +2026-07-02-fs-per-session-cwd.md: aeb17e0a235720bee277f6533468dbdb6cae82dc +2026-07-02-fs-per-session-cwd.zh.md: 34b868761112b3dab31699cd7c945a64e22b7d59 diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md index 0a6d9b85b1..aeb17e0a23 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-02-fs-per-session-cwd.zh.md) + ## Problem The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd Agent Note work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces. diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md index 2c39571136..34b8687611 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md @@ -1,4 +1,4 @@ -# RFC: 相对文件系统路径按调用方的会话 cwd 解析 +# Agent Note: 相对文件系统路径按调用方的会话 cwd 解析 Status: implemented @@ -6,17 +6,21 @@ Status: implemented ## 问题 -ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。 +ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd Agent Note 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。 文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。 +一个有效的绝对 cwd 本身可能看起来有两个父目录:当它包含 `symlink/..` 时,文件系统查找会先跟随符号链接再应用 `..`,而 `path.resolve()` 会从词法上抹掉这两个组件。如果用词法解析沙箱策略却从原始 cwd 启动 bash,就会把权限授予无关的词法父目录、拒绝真实工作区内的写入,并让文件系统工具把相对路径解析进错误目录。 + +普通的符号链接 cwd 在请求的相对路径包含 `..` 时也暴露同一区别:进程从符号链接的物理目标开始遍历,`path.resolve(cwd, path)` 却从其词法拼写开始遍历。因此,对于同一个模型提供的路径,read 会选择与 bash 或沙箱化 mutation 不同的文件。 + ## 决策 -将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash` 对 `workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。 +将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash` 对 `workdir` 的处理方式完全一致。当 cwd 或请求路径任一包含父目录段时,在任何词法 join 之前把 cwd 解析为原生文件系统标识;没有遍历会使标识可观察时,则保留普通 cwd 拼写以供展示。mutation 和沙箱化 bash 调用复用解析后的沙箱策略根目录,使一次调用只有一个工作区标识。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。 -- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`。`opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 则使用后端自身的默认值。采用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。 +- `FileSystem.resolve` 接受 `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>`。`opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 则使用后端自身的默认值。后端执行 I/O 时,`opts.signal` 可以取消解析。options 对象把调用方拥有的两个解析控制项放在一起,避免位置参数继续增长。 - `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`。`config.cwd` 仍作为调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。 -- `dsh-tool-fs` 的 `read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd,并传给 `resolve`。非 agent/无 header 的调用方得到 `undefined`,后端因此应用其默认值。 +- `dsh-tool-fs` 的 `read`/`write`/`edit` 通过共享的 `sessionCwd(exec, requestedPath)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd,并传给 `resolve`。只要任一值中的父目录段可能跨越符号链接,该辅助函数就使用原生 realpath 语义,否则保留普通拼写;沙箱化 mutation 复用完整策略的 `workspaceRoot`;非 agent/无 header 的调用方得到 `undefined`,后端因此应用其默认值。 ## 曾考虑的替代方案 @@ -29,6 +33,7 @@ ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区 ## 后果 - 在 ACP 演示中,fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。 +- 对于包含 `symlink/..` 的会话 cwd,或普通符号链接 cwd 搭配含父目录遍历的相对路径,bash、文件系统工具和沙箱授权都会从同一个物理工作区解析;词法父目录不会获得授权。 - `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的 per-session cwd 产生与 bash 目标相同的 key。 - 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。 - 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml index d05c13ff78..a2f2379e84 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-result-time-applied-hunk-diffs.md: fd02951f21319be2933bf3ffaddeb96bc2a6819c -2026-07-02-result-time-applied-hunk-diffs.zh.md: 4ed9a29bb023343cab28b8114453d17408bc429a +2026-07-02-result-time-applied-hunk-diffs.md: b1e1884f2264f17f6fff17569d79d8352e3fc709 +2026-07-02-result-time-applied-hunk-diffs.zh.md: 091bbb6b9752238348c594a4abe39944b005755d diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md index 4ed9a29bb0..091bbb6b97 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md @@ -1,4 +1,4 @@ -# RFC: 结果时刻的 applied-hunk diff 用于文件变更 +# Agent Note: 结果时刻的 applied-hunk diff 用于文件变更 Status: implemented @@ -8,7 +8,7 @@ Status: implemented [tagged render-intent union](2026-07-02-tool-render-intent-union.md) 为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。 -在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。 +在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本「updated successfully」,没有 diff。 障碍在于一个 seam 边界:`presentResult(args, result)` 是 **`args` + 面向模型的 `result`(`{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容,而 `FsEditOutcome`/`FsWriteOutcome` 只携带替换计数和版本号,没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。 @@ -26,7 +26,7 @@ type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unkn `meta` 是工具自有的 `unknown`,core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回传给 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。 -这是通用形态("工具附加持久化的结果展示"),而非 fs 特有的——任何工具都可以使用。 +这是通用形态(「工具附加持久化的结果展示」),而非 fs 特有的——任何工具都可以使用。 ### 2. 工具计算 hunk;后端返回 before/after(fs) @@ -56,6 +56,6 @@ type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unkn ## 相关 -- 补全了 [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) 中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 +- 补全了 [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) 中作为非目标列出的最后一项表示差异——该 Agent Note 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 - 基于[文件系统 capability seam](2026-06-17-filesystem-capability-seam.md)(before/after 是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)(`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)。 - `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 607ff343ce..6f93853732 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-tool-render-intent-union.md: 6b828c32fb79f43c0f974f2c9f032e11430de5b4 -2026-07-02-tool-render-intent-union.zh.md: 8c44178ca3e8986579e7ed696aec2f900705e9ad +2026-07-02-tool-render-intent-union.md: c7adf8f405000ec7940f82e1e1e2406d86253461 +2026-07-02-tool-render-intent-union.zh.md: c77455b2d8c47add16e36f1b8a8ac3bb12f02707 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index 8c44178ca3..c77455b2d8 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -1,4 +1,4 @@ -# RFC: 用于工具调用展示的带标签 render-intent 联合类型 +# Agent Note: 用于工具调用展示的带标签 render-intent 联合类型 Status: implemented @@ -12,7 +12,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot-golden 回放路径)。 +`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot 回放路径)。 ## 决策 @@ -45,7 +45,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ### 生产者映射 - `dsh-tool-fs` read → `generic`(`kind:'read'`,附带一个 follow-along `location`);write → `diff`(`oldText:null`);edit → `diff`(`oldText:old_string || null`,`newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` 中 Read/Write/Edit 各分支逐字段对应。 -- `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` 和 `bash_output`/`bash_kill` → `generic`。 +- `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` → `generic`。通用 `task_*` 控制工具拥有各自的 generic 卡片。 - `dsh-tool-todo` → `generic`。 ### 终端回退的归属 @@ -72,11 +72,11 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## 非目标 -- **实时增量 `terminal_output_delta` 流式输出**与**命令分类**:终端渲染 RFC 自身推迟的后续工作,本 RFC 不涉及。 +- **实时增量 `terminal_output_delta` 流式输出**与**命令分类**:终端渲染 Agent Note 自身推迟的后续工作,本 Agent Note 不涉及。 ## 相关 -- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 RFC 即为那个联合类型。 +- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。 - 被 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 - 将 `ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view(`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。 - ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。 diff --git a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml index b28a70da27..1f09f82d11 100644 --- a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-03-filesystem-directory-listing-seam.md: 8920e54993eb1402bd310d6ce78fb9866de628c9 -2026-07-03-filesystem-directory-listing-seam.zh.md: 5ca9b195d4bf5c1450c60451dd469a19dd54166f +2026-07-03-filesystem-directory-listing-seam.md: c7db576ff3c7a56622f90a4400bd9297c9591bef +2026-07-03-filesystem-directory-listing-seam.zh.md: 44996d4c680bb2a0c3e880a120e0f9bd420493cd diff --git a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md index 5ca9b195d4..44996d4c68 100644 --- a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md @@ -1,4 +1,4 @@ -# RFC: 为文件系统 seam 添加直接目录列举能力 +# Agent Note: 为文件系统 seam 添加直接目录列举能力 Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 1ea6573424..87ead153fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 1819a730b214270304a8feb103a7f26e9c8d6e6e -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 4211241b1b24fd461f5341bf8d777007a7a36482 +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 2342b89ab666a7987f78cfeabe6fa90ce9dd0cad diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 4211241b1b..2342b89ab6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -1,4 +1,4 @@ -# RFC: Prompt 变量与工具指导归属 +# Agent Note: Prompt 变量与工具指导归属 Status: implemented @@ -10,11 +10,11 @@ Status: implemented **模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何 prompt 文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 -**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml` 和 `examples/acp-agent/cordis.yml` 的 `systemPrompt` 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,stdio 的欢迎横幅也手动枚举了工具集。 +**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。 -**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are coding-agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 +**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 -**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义编写的描述——"a separate agent that works in its own context … it does not see this conversation"——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题:`PromptSection.name` 文档标注为 "(diagnostics / dedup)",但重复项被静默接受。 +**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义编写的描述——「a separate agent that works in its own context … it does not see this conversation」——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题:`PromptSection.name` 文档标注为「(diagnostics / dedup)」,但重复项被静默接受。 ## 决策 @@ -32,7 +32,7 @@ Status: implemented ### Persona 作为 order-0 section -`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,`agent/pre-step` 因此测量的正是用于压缩(compaction)的确切 prompt。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 +`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的 prompt,稍后由 `ctx.tokenMeter` 为压缩压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 ### 工具指导归属 @@ -40,25 +40,25 @@ Status: implemented ### Subagent 对话历史描述符 -`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。 +`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 ## 曾考虑的替代方案 -- **循环自行组合一行 identity 文本**:在必须保持精简的那个包("用插件,不改循环")中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) -- **通过 `agent/request` waterfall 注入模型名称**:prompt 文本在两处组合,且 `agent/pre-step` 的 `fullSystemPrompt` 会遗漏它,导致 compaction 测量的 prompt 与模型实际看到的不一致。 -- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 RFC 要治愈的病症。 +- **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) +- **通过 `agent/request` waterfall 注入模型名称**:prompt 文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在 prompt 中更早出现的声明。 +- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 Agent Note 要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**:`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。 -- **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。 +- **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 ## 不在范围内 -- 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。 +- 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 Agent Note 不认领任何一个。 - 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到 session-cwd 方案重新讨论时。 ## 交付的不变式 -- coding-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 +- tui-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 - fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。 - 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 - 快照回放与 prompt 无关:它按轮次和步骤索引已记录的 chunk 流,不比较发出的请求。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 11d51bfef5..7f8ecda72d 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-reconstructable-requests.md: b07dbceaf6f3ecad64a464df6b97ee2a32284768 -2026-07-05-reconstructable-requests.zh.md: cb57d0915547b8a38b6afde7e36bec36c86e19c3 +2026-07-05-reconstructable-requests.md: 153d37a2faf2265134d5ff9e88f0bbfa275328e0 +2026-07-05-reconstructable-requests.zh.md: c14738f8a2bc8fd25b66a3b3f82b650dd52231c1 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index cb57d09155..c14738f8a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -1,4 +1,4 @@ -# RFC: 每个 LLM(大语言模型)请求都可从会话日志重建 +# Agent Note: 每个 LLM(大语言模型)请求都可从会话日志重建 Status: implemented @@ -8,27 +8,27 @@ Status: implemented 请求流水线未能保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。 -快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只做追加而不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型需要看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。 +快乐路径的参考形态是 MiniCode 的 `LLMClient`:一个有状态的对话客户端,随对话推进只做追加而不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型需要看到的内容时才重置。本 Agent Note 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。 ## 决策 ### 原则 -**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由此推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。 +**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由此推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,因为只有循环会标记请求归属,所以它们不在不变式内。 前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3。 ### 机制 -**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 +**消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 -`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 写入完整的初始、恢复或回退快照。`request/header-delta` 通过公共前缀/后缀行裁剪编码系统变更,通过按名称键控的增/删/改编码工具变更,通过完整替换编码配置或前缀变更。`foldRequestHeader`、`diffHeader` 和 `applyHeaderDelta` 是纯编解码器。每个循环实例在首次请求时写入一个快照以锚定进程边界。delta 只是优化:写入方验证往返等价性,对无法表达的变更(如纯工具重排序)回退到完整快照。 +`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个步骤重建 prompt 组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果被冻结并缓存于该循环实例。`agent/pre-step` 随后接收组合后的前缀,消息在 `step/start` 之前立即被快照。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。 +每个步骤重建 prompt 组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果在通用 `agent/pre-step` 检查点与边界快照之前被冻结并缓存于该循环实例。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。 -**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step` 是当前请求所需内容的 seam。header 重建通过该步骤自身的 `request/header*` 事件折叠,或在无新 header 写入时沿用前一次折叠结果。 +**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step(agent, turn, step, signal)` 仍是当前请求所需内容的通用 seam。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 -**强制执行。** 在开发环境中,`dsh-invariants` 通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环请求通过其冻结形状和 session id 识别;直接的一次性调用被排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。 +**强制执行。** `dsh-agent-loop/invariant` 配套插件向 `ctx.invariants` 注册,并在被选用时通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环通过 `dsh-llm` 的 `markAgentLoopRequest()` 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或 session id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。 ### MiniCode 形态:采纳,但溯源箭头反转 @@ -41,15 +41,16 @@ Status: implemented - **逐次调用的请求标量**(一个可自由变异的配置传给每次 `agent/request` 分发):监听器可以零记账地逐次切换 model,悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态;waterfall(瀑布式事件)提议,日志记录。 - **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。 - **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。 -- **Header 事件上的叙事字段**(delta 上的 `reason`/`changed` 列表):可通过 diff 连续事件推导——每个事实只有一个归宿;快照携带 reason 是因为锚点的成因无法从数据推导。 +- **自定义 header-delta 编解码器**(系统行编辑、按名称键控的工具编辑、完整配置/前缀替换):减少了重复字节,却复制了表示及其 diff/apply/fallback 机制。完整快照只保留一种回放表示。 +- **Header 快照上的叙事性变更字段列表**:可以通过比较连续快照推导。`reason` 仍保留,因为实例边界无法从快照值推导。 ## 后果 - 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。 -- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 -- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一快照不同)。提供方自身的 reasoning-content 排除由服务端管理。 +- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()` 以及工具/prompt-submit 的 `additionalContexts`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 +- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目)、真正的 prompt、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。 -- 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 -- 会话日志每个对话增长一个 `request/header` 快照(系统提示词 + 工具 schema:主导项),加上真正变更时的 delta——相对 `assistant/chunk` 的体量很小;`SESSION_FORMAT_VERSION` 保持 `0`(预发布期间的变动被吸收,后端拒绝而非迁移)。 -- 快照 golden 文件变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 +- 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 +- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对 chunk 密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 +- 快照 expected output 变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 - FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(reasoning 选项、额外 body 参数)应归属何处。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 1de1e4c13d..8f73ac8dbe 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-subagent-provider-lifecycle-events.md: b5300706ce4c53a6d348a86803d221a4dcca9632 -2026-07-05-subagent-provider-lifecycle-events.zh.md: 8694612e5e3d4d65117896e337c170f47b31abb0 +2026-07-05-subagent-provider-lifecycle-events.md: afd45027e8b56cbf1d17e6dec749d8602c81124d +2026-07-05-subagent-provider-lifecycle-events.zh.md: be94de29d8fadafb08acc95f1eb15e2fb931b3bb diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index 8694612e5e..be94de29d8 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -1,4 +1,4 @@ -# RFC: Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed` +# Agent Note: Subagent 提供方生命周期事件——`subagent/provider-added` / `subagent/provider-removed` Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -[prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述(`providerWording`),使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[prompt-variables Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 -如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求("在 cordis.yml 中把后端列在工具前面")。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——"异步状态不是同步状态"(见[防御性模式](../../../defensive-patterns.md))。 +如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.md))。 ## 决策 @@ -23,14 +23,14 @@ Status: implemented ## 曾考虑的替代方案 -- **在 `apply` 时解析提供方,不存在则抛异常**:否决。"先列后端"这一要求声称了 Loader 并不存在的顺序保证。 +- **在 `apply` 时解析提供方,不存在则抛异常**:否决。「先列后端」这一要求声称了 Loader 并不存在的顺序保证。 - **重试查找(轮询直到提供方出现)**:最终能收敛,但在框架已有的机制(effect 注册 + disposal)之外发明了一套私有就绪协议;它也无法感知提供方离开,因此 HMR 会遗留一个措辞描述已 dispose 后端的工具。 -- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但将 tool-choice 引导移出了描述,与 prompt-variables RFC 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 +- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但将 tool-choice 引导移出了描述,与 prompt-variables Agent Note 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 - **根据提供方名称而非提供方对象确定措辞**:`providerName` 本身是配置,重命名后的提供方会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。 ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 -- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../cordis-catalog/events.md)与[生产者/消费者映射](../../../event-producer-consumer.md)。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/cordis-catalog/events.md)与[生产者/消费者映射](../../../../docs/event-producer-consumer.md)。 - **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持 prompt 组装的时效性。 - **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 57b6fca44e..fe015dff1b 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-timeout-deadline-library.md: e8a688fbd5285c5e177eca5339bb204e2e4b01bc -2026-07-06-timeout-deadline-library.zh.md: 427aa4e10fe90a69b8773ed739d92e37754a4998 +2026-07-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 +2026-07-06-timeout-deadline-library.zh.md: c6e5706e9a62877dc71d56951e82bc892b02a75e diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 427aa4e10f..c6e5706e9a 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -1,4 +1,4 @@ -# RFC: 共享的超时/截止时间原语,硬终止留给各能力自行实现 +# Agent Note: 共享的超时/截止时间原语,硬终止留给各能力自行实现 Status: implemented @@ -20,7 +20,7 @@ Status: implemented ### 库的对外接口 -三个函数加一个 reason 类型: +四个函数、一个 watchdog 接口加一个 reason 类型: ```ts ignore-check /** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */ @@ -53,19 +53,34 @@ export function deadline( code: string, ): { signal: AbortSignal; [Symbol.dispose](): void } +/** A stable signal plus one-at-a-time, timer-guarded async-iterator demand. */ +export interface IdleWatchdog { + readonly signal: AbortSignal + next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>> + [Symbol.dispose](): void +} + +/** Arm only while one iterator `next()` is outstanding, then rearm on later demand. */ +export function idleWatchdog( + upstream: AbortSignal | undefined, + timeoutMs: number, + code: string, +): IdleWatchdog + /** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined ``` -`deadline` 通过 `AbortSignal.any` 将上游信号与定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的「无超时」哨兵,用于后端拥有的后台任务;外部提示经过 `clampTimeout`,必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。提供方将超时原因转译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。 +`deadline` 通过 `AbortSignal.any` 将上游信号与一次性定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的「无超时」哨兵,用于后端拥有的后台任务;外部提示经过 `clampTimeout`,必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。`idleWatchdog` 则要求正有限的间隔,在整个流期间保持一个稳定的融合信号,并且只在一个迭代器 `next()` 尚未结算时启动定时器;结算会解除定时器,后续 demand 会重新启动,并发 demand 会失败,dispose 会清除当前 arm。提供方将超时原因转译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。 ### 职责划分 | 关注点 | 负责方 | |---|---| | 校验请求提示并钳位默认值/最大值 | `dsh-timeout`(`clampTimeout`):纯算术加共享的正有限请求契约 | -| 启动定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) | -| 清除定时器 | `dsh-timeout`(`[Symbol.dispose]`) | +| 启动一次性定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) | +| 仅围绕未结算的迭代器 demand 启动和重启 | `dsh-timeout`(`idleWatchdog`) | +| 清除定时器 | `dsh-timeout`(任一原语的 `[Symbol.dispose]`) | | 中止后对首个 abort reason 进行分类 | `dsh-timeout`(`timeoutOf`) | | **实际终止工作** | 各能力的实现 | | 默认值/最大值*数值* | 各能力的配置 | @@ -77,6 +92,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 - **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 +- **LLM 适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在 chunk 之间花费的时间。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 ## 后果 @@ -84,6 +100,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。这是与字面提案形状(向 `runBash` 传入 `timeoutMs: 0`)的唯一偏差;一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 +- 模型流现在共享一个可重启的定时器契约,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其 tool-schema/snapshot 覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index 1d65850a78..f98cd104aa 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-tool-call-timeout-policy.md: 20e0b5d70166404e034c0d0ece75cec61cab0df9 -2026-07-07-tool-call-timeout-policy.zh.md: 00b85ce9e2e7ae4ba31a9770beefce9c2eb8c319 +2026-07-07-tool-call-timeout-policy.md: 538225b197f7dd62b4798c037b72bf3cfc40648a +2026-07-07-tool-call-timeout-policy.zh.md: e37c8099a7780a8aa44f3154ceaf7ffa772f1e3b diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index 00b85ce9e2..e37c8099a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -1,4 +1,4 @@ -# RFC: 工具调用超时策略作为插件 +# Agent Note: 工具调用超时策略作为插件 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[超时/截止时间 RFC](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 +[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.bash` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子调用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 @@ -52,9 +52,9 @@ catch 是基础 `next`(而非 waterfall 之外的东西)这一点至关重 searchTimeoutMs: 30000 ``` -超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号,之后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 +超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消契约](2026-07-19-cooperative-tool-cancellation.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 -信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此 Cordis 的惯用方式——修改共享对象再委托——是唯一能到达分发的机制。插件在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的(可能已中止的)截止信号。 +信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此修改共享对象是包装器向注册表提供截止信号的方式。注册表会在进入工具体前再次融合已捕获的调用方信号;插件则在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的截止信号。 `timeout-policy` 拥有 `TOOL_TIMEOUT` 代码的两种用途:传递给 `deadline()`/`timeoutOf()` 的内部截止代码(有作用域,使嵌套的外层截止读为普通取消)和结构化工具结果错误代码。其替换结果为: @@ -80,13 +80,13 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { `bash` 保持当前的后端超时路径。`dsh-tool-bash` 继续暴露 `timeoutMs` 和 `run_in_background`;`dsh-bash-local` 继续使用 `@deepseek-ai/dsh-timeout` 处理 `BASH_TIMEOUT`;钩子桥接继续调用 `runHook()` 并通过 `ctx.bash` 传递 `timeoutMs`。这保持了前台/后台/钩子行为的稳定。 -`read`、`write`、`edit`、`todo_write`、`bash_output` 和 `bash_kill` 不加入工具调用超时:它们是本地文件系统或短暂的注册表/会话操作,截止时间对它们而言要么只能尽力而为,要么没有必要。 +`read`、`write`、`edit`、`todo_write`、`task_list` 和 `task_kill` 不加入工具调用超时。`task_output` 自己拥有有界等待,因为等待超时是成功的实时状态结果,而非工具失败。 未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现而无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对这类工具造成问题,bash seam 可以后续添加调用方自有截止模式;这不在本次范围内。 ## 曾考虑的替代方案 -**将插件命名为 `tool-timeout`。** 字面的 RFC 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包(package)为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 +**将插件命名为 `tool-timeout`。** 字面的 Agent Note 名称匹配了 `gen-tool-catalog` 完整性守卫的 `packages/*/tool-*` glob,该 glob 要求每个匹配项注册一个面向模型的工具。本插件不注册任何工具——它是一个 `tools/execute` 包装器——因此 `tool-*` 名称要么导致 `verify-tool-catalog` 失败,要么强制产生一个误导性的启动条目。包(package)为 `@deepseek-ai/dsh-timeout-policy`,位于新的 `packages/timeout/` 组;cordis.yml 的 `id` 仍可为 `timeout-policy`。 **仅保留逐工具的超时处理。** 这是 `bash` 和 `web_fetch` 的既有形态,也与 Claude Code 和 Codex 对 shell 命令的做法一致。它对 web 类工具不利,因为每个新的支持超时的工具都必须自行选择校验方式、上限语义、文档、快照和分类。插件集中了策略和分类,让每个工具的 schema 专注于业务输入。 @@ -100,12 +100,12 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { **使用 `tools/pre-execute` 加 `tools/post-execute` 代替新的环绕 seam。** pre 监听器可以启动截止时间并修改 `exec.signal`;post 监听器可以分类并替换。这样做的问题是截止时间的生命周期会跨越两个独立的 waterfall:需要 call-id 映射、在每条 pre-deny/tool-throw/post-throw/dispose 路径上清理,以及与其他监听器的排序规则。`tools/pre-execute` 也是允许/拒绝门禁,而非执行包装器。`tools/execute` 给超时一个词法作用域:启动、委托、分类、释放。 -**使用 `Promise.race` 对非协作工具强制超时。** 与超时库 RFC 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。 +**使用 `Promise.race` 对非协作工具强制超时。** 与超时库 Agent Note 相同的理由否决:它在底层进程、fetch 或提供方操作可能仍在运行时就将控制权返回给调用方。插件只发送信号;终止仍是实现方的责任。 ## 后果 - `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是狭窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到原始的工具抛出。 - 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 -- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 +- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。注册表会等待这一未达静止状态的工具体,而不是竞速它;同时插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 - 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。 -- 与字面提案的偏差,按 implemented-RFC 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。 +- 与字面提案的偏差,按 implemented-Agent Note 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index fda6a0406b..63e93dd74a 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-agent-scope-contexts.md: b67d51f06f74f0460f76e9fc4b45699d00b7db46 -2026-07-08-agent-scope-contexts.zh.md: e37ea60418095cb34d354150aed79a937b1b3552 +2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 +2026-07-08-agent-scope-contexts.zh.md: ff8243ebdd541e4a7821d9c4ff99217a8060c2b4 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index e37ea60418..ff8243ebdd 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -1,4 +1,4 @@ -# RFC: agent 即注册作用域 +# Agent Note: agent 即注册作用域 Status: implemented @@ -16,7 +16,7 @@ Status: implemented 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的 context 进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 -Cordis 是 SDK 底层的插件框架。Cordis **context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该 context。[Cordis 入门](../../../cordis-primer.md)对该框架有更详细的说明。 +Cordis 是 SDK 底层的插件框架。Cordis **context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该 context。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。 对大多数贡献者而言,完整契约是四条规则: @@ -45,7 +45,7 @@ flowchart LR 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 RFC](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 @@ -62,8 +62,7 @@ flowchart LR ```js const handle = await ctx.agents.create({ - agentId: AgentId('reviewer'), - sessionId: SessionId('reviewer-session'), + sessionId: SessionId('reviewer'), agentOptions: { model: 'model-name' }, setup(agentCtx) { agentCtx.systemPrompt.section({ @@ -105,7 +104,7 @@ setup 接收一个完整的受信 Cordis context,因此可以组合普通插 在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 -以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册 context。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../cordis-catalog/events.md)是详尽的事件参考。 +以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册 context。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../../docs/cordis-catalog/events.md)是详尽的事件参考。 ### 创建最后发布,dispose 最后撤销 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index c2a5189641..2de6e74661 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-agent-scope-runtime-design.md: 1940423db9364f56ab8a13e4d636f492f97f2d54 -2026-07-12-agent-scope-runtime-design.zh.md: bbfb06db7eb886f7bc34cdb8607729fb892cf5ba +2026-07-12-agent-scope-runtime-design.md: ff7fba1e6f8d496080acbceddb06691c8cddc5f5 +2026-07-12-agent-scope-runtime-design.zh.md: 0a101e796658dd48bc76425fb39362f35e7e345e diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index bbfb06db7e..0a101e7966 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -1,4 +1,4 @@ -# RFC: Agent 作用域运行时设计与正确性 +# Agent Note: Agent 作用域运行时设计与正确性 Status: implemented @@ -14,13 +14,13 @@ Status: implemented ## 决策 -运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体;每个活跃的注册表对象有一条入口记录;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和静默态。 +运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体与共享 layer store;每个活跃的注册表对象有一条入口记录;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和静默态。 该设计可概括为七项选择: | 问题 | 权威机制 | |---|---| -| 选择全局加某个 agent 的注册 | 不透明作用域键与路由载体 | +| 选择全局加某个 agent 的注册 | 不透明作用域键、路由载体与共享 layer store | | 拥有一个活跃的 agent 或会话 | 由其 disposer 捕获的单条注册表入口 | | 协调创建/恢复 | 单个 `AgentCreationTransaction` | | 保护持久化、队列、模型或协议格式数据 | 在该边界处一次性物化 | @@ -28,9 +28,9 @@ Status: implemented | 组合模型可见的 prompt 与工具表面 | 单个共享工具视图加权威的 assembly-waterfall 结果 | | 协调 subagent、worker 和进程关闭 | 单个取消信号加该边界独立的终止态/静默态事实 | -本 RFC 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与 prompt、subagent 与工作流,最后是可执行检查。 +本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与 prompt、subagent 与工作流,最后是可执行检查。 -[7 月 8 日 RFC](2026-07-08-agent-scope-contexts.md) 仍然是贡献者契约。独立的 [subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md)仍然是贡献者契约。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 ## Cordis 模型:context、fiber、effect、receiver 与 waterfall @@ -70,11 +70,11 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个 Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。 -### 注册表读取叠加一个精确映射 +### 注册表读取叠加一个精确 layer -作用域感知的注册表将全局贡献与按标识键索引的局部贡献分开存储。读取解析全局层和至多一个局部层;它从不遍历父级链。 +作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个 context 派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。 -每个服务保留其领域规则。命名 prompt 值和工具使用局部遮蔽,工具限制在添加局部工具之前过滤全局,事件选择监听器受众而非注册数据。Scope 提供标识和所有权,而非通用的合并算法。 +每个服务保留其领域规则。命名 command 和 prompt 视图使用共享的、保持插入顺序的 shadow merge;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。Prompt 变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 ### 融合 dispatch 辅助函数防止主体漂移 @@ -208,7 +208,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 -[subagent 组合控制 RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 +[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule)拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 `ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。 @@ -324,19 +324,19 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 ### 运行时不变式覆盖跨服务事实 -不变式插件验证每个声明的作用域事件使用带标记的载体,以及暴露主体的事件族使用匹配的键。Session trace 验证在追加提交前暂存,并在同一事件提交后推进。 +`dsh-scope/invariant` 配套插件在被选用时验证每个声明的作用域事件使用带标记的载体,以及暴露主体的事件族使用匹配的键。独立的 `dsh-session/invariant` 贡献在追加提交前暂存 trace 验证,并在同一事件提交后推进;二者都通过 `ctx.invariants` 注册。 该插件不通过扫描注册表来管控可信 setup,也不拒绝通过强制转换构造的 prompt assembly 对象。这些检查会将组合契约变成推测性的运行时机制,却不保护真实的外部边界。 ### 生成的产物使公开契约保持对齐 -事件目录、服务目录、生产者/消费者矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 RFC](../process/2026-07-14-typescript-program-backed-semantic-gates.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 +事件目录、服务目录、生产者/消费者矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md)拥有 Program 构造、语义事件发现和解析器生成规则。 行为测试固定了作用域路由和 dispose、最终入口碰撞清理、发布回滚、有序静默、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 -[7 月 8 日 RFC](2026-07-08-agent-scope-contexts.md#alternatives-considered) 拥有公开扁平作用域契约的替代方案。此处的替代方案关注实现形态。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md#alternatives-considered)拥有公开扁平作用域契约的替代方案。此处的替代方案关注实现形态。 ### 使用透明代理作为作用域载体 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml index f42c8c462e..5d47778692 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-14-acp-agent-client-protocol.md: 50ad2620de1dada78c4a20101b631cc5b0e9f3d1 -2026-06-14-acp-agent-client-protocol.zh.md: c4b73c00083d63e5c024953cd8c9d8dbd94d6b0a +2026-06-14-acp-agent-client-protocol.md: c6976ed28a254684fca62e2d309cdde7ea90340d +2026-06-14-acp-agent-client-protocol.zh.md: 711e8c2a7f7146494fa208942523e2ebfa1808a5 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md index c4b73c0008..711e8c2a7f 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md @@ -1,4 +1,4 @@ -# RFC: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent +# Agent Note: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent Status: implemented @@ -50,7 +50,7 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本 编辑器可以通过一条 ACP 连接创建、加载、提交 prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。 -桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、运行时模型选择、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。功能清单将这些记录为不支持,而非静默接受。 +桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。 空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml index 5a79faa4da..0b5380e4d4 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-14-acp-multi-session.md: a71f2d3d2daff3c8fae460e414d1f50facd5538f -2026-06-14-acp-multi-session.zh.md: 8b5ba46e949480d450f13aadad3ea82e0e048161 +2026-06-14-acp-multi-session.md: 43aa32a43fef7aa69f5efd45d4c8176da9f459cd +2026-06-14-acp-multi-session.zh.md: 012752273b469295fcf8338f809dca252fc377ac diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md index 8b5ba46e94..012752273b 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -1,4 +1,4 @@ -# RFC: 在单个连接上多路复用并发 ACP 会话 +# Agent Note: 在单个连接上多路复用并发 ACP 会话 Status: implemented @@ -10,16 +10,24 @@ Status: implemented ## 决策 -ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中,并维护一个 `WeakMap<Agent, SessionId>` 反向索引,用于 agent 作用域的回调。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。 +ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。 每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。 -权限归属使用同一个反向索引。ACP `approval/request` 应答器只向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。 +权限归属使用对正向 map 的同一精确 agent 检查。ACP `approval/request` 应答器只向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。 后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`bash_output` 和 `bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。 连接拆除时清空活跃 map,将每个待处理的 prompt 以取消状态结算,并并行 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`;它只是从已经支持的「每会话一个主根目录」路径中移除了进程级根目录限制。 + ## 曾考虑的替代方案 **每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 6beb8d876f..97e09d562e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-15-code-mode.md: fb95f3010121fa432b3ac24e6aa477b1205a0ba7 -2026-06-15-code-mode.zh.md: 2da6628c1842bfd507c62d4b66a91f323e6fd0e7 +2026-06-15-code-mode.md: f403e87601aad0fda5d53ec5b94ba44452e79b49 +2026-06-15-code-mode.zh.md: b4b432a6ce2206ea4920f89275d431ed2d3d0c44 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 2da6628c18..b4b432a6ce 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -1,4 +1,4 @@ -# RFC: Code Mode——模型针对工具注册表编写 TypeScript +# Agent Note: Code Mode——模型针对工具注册表编写 TypeScript Status: implemented @@ -6,20 +6,20 @@ Status: implemented ## 问题 -在注册表的原生呈现方式下,agent loop(智能体循环)将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式(wire format)上(也记录在请求头日志中),模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../architecture.md) 中明确标注的 open TODO),且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。 +在注册表的原生呈现方式下,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(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位实现:Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。 +工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 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)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 -2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。 +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 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 ### 注册表拥有模式 @@ -40,15 +40,15 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: -1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 +1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 -**子调用的 `additionalContext` 被省略。** 在 `run_code` 期间注入它会破坏父调用/结果的相邻性,而一个程序可以产生多个 context。支持它需要一个复数通道或循环级别的子分发缓冲区。 +**子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute block 会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 **并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。 -**呈现。** `run_code` 的 render intent 按 [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 +**呈现。** `run_code` 的 render intent 按 [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 ### 可观测性:`tool/code-dispatch` @@ -62,7 +62,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 - `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。 - `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,都解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝。 - `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` -- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——按[防御性模式](../../../defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时。 +- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; 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 生成器。 @@ -76,7 +76,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。 4. **通过 message port 桥接绑定**: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` 约束总经过时间,包括未解析的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。 -6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../defensive-patterns.md)。 +6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.md)。 ### 信任姿态 @@ -88,18 +88,18 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw ## 后果 -切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,桥不传播每次调用的 `additionalContext`,直到为 Code Mode 设计好这些契约。 +切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。 ## 测试 - **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、恶意绑定流量、空环境、structured-clone 回退、输出上限和 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。 -- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、省略的 `additionalContext` 和 HMR(热模块替换)清理。 -- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;测试验证折叠的请求头、关联的分发事件、结果文件和策展后的回答。 -- **快照:** `code-mode-turn` 和 `both-mode-turn` fixture(测试前置数据)固定 SDK 段、请求头工具列表、分发事件和结果卡片。 +- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层 block 抑制以及 HMR(热模块替换)清理。 +- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 +- **快照:** `code-mode-turn`、`both-mode-turn` 和 `code-mode-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 ## 曾考虑的替代方案 -**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 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 仪式。 @@ -121,7 +121,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw **`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。 -**SDK 的 prompt 成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 RFC 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 +**SDK 的 prompt 成本,尤其在 `'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` 之后。 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index b3ed1ae8ee..35f14a2678 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-filesystem-tool-schemas.md: dba96157a0df548e4f153a0e233238af7400c087 -2026-06-17-filesystem-tool-schemas.zh.md: 3c135de9fa81fb333abc5fa6001a7ce7d7525a9d +2026-06-17-filesystem-tool-schemas.md: fa23dccc7ae98a9c25b9c474a75dc1e2f8e5ff21 +2026-06-17-filesystem-tool-schemas.zh.md: 30acf26a9bb2858a9f786591b6e01626593713d0 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index 3c135de9fa..30acf26a9b 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -1,4 +1,4 @@ -# RFC: 文件系统工具 schema——面向模型的读/写/编辑接口形状 +# Agent Note: 文件系统工具 schema——面向模型的读/写/编辑接口形状 Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -[文件系统能力 seam RFC](../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 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFC 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 +[文件系统能力 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 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../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 上有所不同;本 RFC 为原型选择最小的共有接口。 +该 schema 应足够小,以便在 `dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统中照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 Agent Note 为原型选择最小的共有接口。 ## 决策 @@ -105,8 +105,8 @@ schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒 ## 后果 -**首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 RFC 或聚焦的后续工作形式到来,而非对初始 schema 的重载。 +**首版 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` 会搅动提示词、示例和下游客户端。本 RFC 预先选择 snake_case,并将其视为稳定的面向模型的契约。 +**命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 Agent Note 预先选择 snake_case,并将其视为稳定的面向模型的契约。 diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml index b25f0bf18b..5ca5a1e59d 100644 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-acp-terminal-and-tool-rendering.md: 9bcb65a1e0b316d0b596a80816647d46ccfca782 -2026-06-18-acp-terminal-and-tool-rendering.zh.md: 3899e3ed21b386b1652736bc3de41d338efaa30e +2026-06-18-acp-terminal-and-tool-rendering.md: 166e52f8e659a78b1347279c61956ca1001e4939 +2026-06-18-acp-terminal-and-tool-rendering.zh.md: 924ea9fb50a77314748d4826b5c408d5243b32f9 diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md index 3899e3ed21..924ea9fb50 100644 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md @@ -1,4 +1,4 @@ -# RFC: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 +# Agent Note: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 +ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) 与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。 @@ -45,4 +45,4 @@ Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` ## 超出范围 / 非目标 -文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 RFC:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。 +文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 Agent Note:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index d99478bc53..4edec28660 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-compaction-capability-seam.md: e88b3fd8267f65bba136398df9c439e371237912 -2026-06-18-compaction-capability-seam.zh.md: 75049079a2d85e27301e53b891228c1cbc87fac3 +2026-06-18-compaction-capability-seam.md: 07da796fdabffb8a43950ad501f5623d4474923a +2026-06-18-compaction-capability-seam.zh.md: 18ef9a8d833bdaa924324867d522399c0ad48243 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index f609473a9c..07da796fda 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 75049079a2..18ef9a8d83 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -1,4 +1,4 @@ -# RFC: 压缩作为能力 seam(抽象契约 + 基础后端) +# Agent Note: 压缩作为能力 seam(抽象契约 + 基础后端) Status: implemented @@ -8,57 +8,58 @@ Status: implemented 长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即截断响应(`max-tokens`)或性能退化。**上下文压缩(context compaction)** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。 -[session surface](../../implemented/architecture/2026-06-18-session-surface.md) 正是为此而构建的基础设施:一条建立在事件日志之上的链表,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段节点并插入替换内容,`sourceEventSeqs` 记录来源以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 +[session surface](../architecture/2026-06-18-session-surface.md) 正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段条目并插入替换内容,`sourceEventSeqs` 记录溯源信息以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 -两股力量塑造了设计。第一,压缩是**可替换的**:token 计数可以是 char/4 启发式或真实 tokenizer,摘要生成可以是模型调用、模板或远程服务——它们独立于*何时*以及*压缩哪段范围*而变化。第二,`SurfaceEventType` 封闭为五种事件类型(`user/message`、`assistant/message`、`tool/result`、`context/message`、`steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上——编译器拒绝在其上附加 `surfaceOp`,invariants 插件在运行时也会拒绝。 +两股力量塑造了设计。第一,压缩策略与可复用的 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 RFC](../../implemented/architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进: +遵循[能力 seam Agent Note(agent 决策记录)](../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`,拥有完整算法——token 估算(每 token 字符数,即 `charsPerToken` 配置,默认 4,加上每块开销)、尾→头保留遍历、通过 `ctx.llm.stream()` 进行摘要生成、surface 替换、锁,以及 `agent/pre-step` 自动压缩监听器。基于 tokenizer 或模板的后端是同级包(或覆盖两个 protected 估算/摘要钩子的子类)。 -3. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 RFC 范围之外,以便 seam 先稳定下来。 +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 RFC 规定接口包"仅依赖 cordis"(对 `dsh-bash` 成立,因为其词汇是自包含的)。压缩**无法**遵守这一点:它的动词定义*在* `Session` 之上(`compactRegion(session, start, end)`),其输出*就是*内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `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 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。 +这不是耦合异味,而是契约的领域所在。「仅 cordis」的指导原则一直是「接口仅依赖契约真正需要命名的东西,绝不依赖实现」的简写。`dsh-session` 和 `dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。 ### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端 -早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,仅 `estimateContentTokens()` 和 `summarize()` 为抽象。这会将契约重新耦合到一种策略:想要不同保留策略或不同事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端——它本该在那里——并让接口保持为纯粹的*做什么*声明。后端内部仍有分层——`estimateContentTokens()` 和 `summarize()` 是 `protected` 钩子,子后端可以覆盖而无需重新实现遍历——但那是后端的私有关注点,不是契约的。 +早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。 -`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` 接收**必需**参数(而非最初的全可选形式)。自动压缩 seam(见下文)总是提供 agent、生命周期上下文、组装好的系统提示词(计入估算)和轮次的 abort signal,因此可选性只会在 seam 处引入隐藏的默认值。被压缩的会话来自 agent 上下文。`compactRegion(session, start, end, agent, turn, step, signal?)` 保留可选的 signal(手动调用方可以省略)。传递生命周期上下文而非具体模型,使路由 agent 保持诚实:后端的摘要请求可以走 `agent/request`,模型路由插件在那里已经选择了实际模型。 +`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/pre-step` 运行——一个专用的 surface 变更 seam +### 成功的持久步骤工作完成后运行自动压力检查 -压缩会变更 session surface,因此在步骤开启之前、消息派生之前运行。`agent/request` 保持为调用配置变换,无需在 surface 变更后重建历史。 +成功调用的压力检查不能在步骤前运行,因为最终的 `agent/request` 路由、提供方输出、工具结果、缓冲上下文与 steering 当时尚不存在。串行的 `agent/post-step(agent, turn, step, signal)` 会在这些事实持久化后、`step/end` 之前触发。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。 -解决方案是一个专用的循环 seam:**`agent/pre-step`**(`@mode serial`),由循环在系统组装*之后*、步骤开启(`step/start`)*之前*触发: +规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`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)。 ``` -assembly = ctx.systemPrompt.assemble() -await ctx.serial('agent/pre-step', agent, turn, step, system, signal) ⟵ compaction mutates the surface here -session('step/start') ⟵ the step opens AFTER the seam -messages = session.deriveMessages() ⟵ single derive, reflects the compaction -request = waterfall agent/request ⟵ pure request transform (hooks, model switch) -``` +assistant/message → tool/result/context/steering +await serial agent/post-step ⟵ pressure compaction inside the successful step +step/end -循环在 `agent/pre-step` 之后派生一次消息。在 `step/start` 之前运行,使压缩记录位于任何半开步骤之外,简化崩溃修复。该 seam 是 awaited 且串行的,因此 surface 变更不会交错;监听器返回 `void`,不使用 Cordis bail 值作为否决。 +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 在*一轮之内*就会增长。单独一轮就可能超出窗口("失控轮次"),而在下一次模型调用溢出之前唯一能挽救的时机是下一步的 `pre-step` 检查点。如果将压缩限制在轮次的第一步(或者更糟,逐字保留整个进行中的轮次),恰好重新打开了压缩存在的意义所要堵住的缺口:harness 会在最需要压缩时崩溃。 +自动压缩在**每个成功的**步骤之后检查,而非每轮一次。这对失控轮次存活至关重要:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 会在一轮之内增长。步骤后检查可以在后续步骤开启前压缩早期已关闭的工具对;如果请求率先越过限制,由提供方确认的溢出仍是兜底机制。 -`compactIfNeeded` 保留估算大小达到 `retainTokens` 的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。 +`compactIfNeeded` 保留估算大小达到解析后保留 token 预算的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`dsh-compact` 导出前后边缘辅助函数;只要 `replaceGeneration` 不变,其逐会话缓存就只折叠新增的 surface 尾部节点,面对仅日志增长时不读取事件,并在替换后重建当前成员关系与平衡。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。 因此失控轮次的压缩方式与其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`)并在该步骤关闭后重试。 -**单单元溢出不在范围内,这是有意为之。** 如果单个被保留的单元——一个已关闭步骤,或一个大型自由节点(如粘贴的 `user/message`)——*单独*超出预算,压缩无能为力,下一次模型调用可能超预算发出。限制单个单元的大小是另一个关注点(输出截断),在别处处理;压缩对此不作承诺,而没有这种机制的 harness 仍然可能在单个超大单元上崩溃。这里诚实地指出这一点,而非掩盖。 +**部分单单元溢出仍不在范围内。** 摘要范围选择无法拆分不可分割的单元。当可移除的文本型工具结果内容占据大部分空间,且修剪后的余量能够容纳时,可选修剪器可以修复一个已关闭的工具对。仅信封压力、粘贴的 `user/message` 等不可分割的超大非工具节点,以及不可修剪余量仍然过大的工具单元,依旧不属于压缩范围;限制这些单元是另一个关注点。 ### 头部锚定:一个自动检查点,始终在头部 @@ -66,11 +67,11 @@ request = waterfall agent/request ⟵ pure request transform (hooks, model s ### 近似收敛不变式 -`resolveConfig` 校验数值参数,但不基于虚构的摘要长度不变式来拒绝。收敛是动态的:提供方的输出上限可能被隐藏或显式的推理 token 消耗,模型可能生成不可预测大小的摘要。`maxTokens` 仅是摘要调用的提供方侧生成上限;推理块在检查点存储前被剥离。如果压缩后的 surface 仍超阈值,`compactIfNeeded()` 最多额外重压缩头部检查点 `compactionRetries` 次,但每次提交的摘要必须小于其遮蔽的内容。唯一的残余情况是上述单单元溢出(一个向后取整的超大步骤可能将保留尾部推过预算),这恰好是上述范围外的关注点,而非抖动 bug。 +`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`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的节点*和*簿记事件。`compact/*` 事件是纯日志记录(锁 + 来源)。surface 变更位于锁**内部**——`compact/end` 是最后追加的事件: +由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的条目*和*簿记事件。`compact/*` 事件是纯日志记录(锁 + 溯源信息)。surface 变更位于锁**内部**,`compact/end` 是最后追加的事件: ``` compact/start → log-only. Acquires the lock. @@ -81,7 +82,7 @@ user/message → surfaceOp { op:'replace', start, end }. THE surface mutatio compact/end → log-only. Releases the lock (carries `error` on a recoverable failure). ``` -`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_nodes]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。 +`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_entries]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。 ### 检查点框架 + 增量合并(后端私有) @@ -92,12 +93,14 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab `compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序: 1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end` 的 `compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。 -2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在 awaited 的 `pre-step` 上是单线程的,因此这也是重入绊线——抛出"already in progress"表示真正的 bug。) +2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start`,`compactRegion` 拒绝启动。(循环在任一 awaited 自动 seam 上都是单线程的,因此这也是重入绊线——抛出「already in progress」表示真正的 bug。) + +该锁只排除另一项压缩,不排除无关的仅日志事实。基础后端会在 `compact/start` 之后对 token meter 的 surface 节点取快照,并在异步摘要后再次比较;任何 surface 变更都会使替换前的检查失败,而标题或其他仅日志追加不会使已选范围失效。 两种失败路径,均有文档记录: -- **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`,无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**——surface 替换从未落地,因此完整的未压缩历史正确派生。通用轮次修复(`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围内的进行中检查永远看不到它,崩溃不会卡住未来的压缩。压缩在下一个 `pre-step` 简单地重新尝试。 -- **可恢复**(摘要生成抛出异常但循环存活):后端追加带有 **`error`** 字段的 `compact/end`,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` 事件。 @@ -105,23 +108,24 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ## 曾考虑的替代方案 -- **完整算法作为接口的具体方法**(仅估算/摘要为抽象)——早期草案;否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;`protected` 的估算/摘要钩子是后端的私有分层,不是契约的。 -- **在 `agent/request` waterfall(瀑布式事件)上执行压缩**——早期方案;否决,因为它强制双重派生,且将监听器上下文交给了结构上无法压缩的对象。专用的 `agent/pre-step` seam 从构造上使分层正确。 +- **完整算法作为接口的具体方法**——否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;可复用测量属于单独的 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`(后端),位于 `packages/compact/` 下,接入根 tsconfig。消费方层推迟。 -- **新循环 seam**:`agent/pre-step`(`@mode serial`),在 `dsh-agent` 中声明,由 `dsh-agent-loop` 在系统组装之后、`step/start` 之前触发。这是对循环的文档化变更——`docs/architecture.md` 记录了它,生成的 cordis catalog 携带其签名。 +- **包**:`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`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 -- **`dsh-session`** 获得工具配对平衡谓词(`isToolPairingBalanced`,位于 `tool-pairing.ts`,从包索引导出),`compactRegion`/`compactIfNeeded` 用它确保折叠区域不会拆分步骤的工具调用/结果对。surface 的 `replace` 操作和 surface 元数据运行时守卫已经存在并被复用。 -- **`dsh-invariants`** 移除其 `surface replace: start must be <= end` 断言:头部锚定的压缩将高序号替换节点放在较旧范围的*位置*上,因此 `start > end` 在数值上是正常且有效的(范围是位置性的,由 surface 的 `indexOf` 检查验证,这些检查保持不变)。轮次封闭不变式原样复用。 -- **接线**:`dsh-compact-basic` 在 `examples/coding-agent` 的 `cordis.yml` 中加载,使 seam 在真实演示中生效(此前它未被任何地方加载)。 +- **`dsh-compact`** 拥有 `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` 的两种结果、头部锚定、开放尾部拒绝、惰性崩溃孤儿,以及在一个超大开放轮次内压缩已关闭步骤。 -- **循环测试:** 测试固定每步在 `turn/start` 与 `step/start` 之间有一次 awaited 的 `agent/pre-step`;在该处的 surface 变更落在步骤之外,并出现在单次派生的请求中。 +- **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、修剪配置与回放、富块顺序、元数据保留、收敛、`compact/end` 的两种结果、开放尾部拒绝、仅修剪与带摘要的溢出恢复、generation 证明、上限和原始错误保留。 +- **循环测试:** 测试固定步骤后处理发生在持久工具结果之后、`step/end` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 - **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 20172e4369..35b3e3032b 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-21-subagent-capability-seam.md: ff8ca7d6292055715d94b3878860bffafb8a8057 -2026-06-21-subagent-capability-seam.zh.md: c11623531c753cd454621f450f33cff5edbbf815 +2026-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47 +2026-06-21-subagent-capability-seam.zh.md: 9cab992e86120dd32cedea05b63c777b8678d409 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 3ed2090b22..9c17a93751 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index c11623531c..9cab992e86 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -1,18 +1,18 @@ -# RFC: Subagent 能力 seam +# Agent Note: Subagent 能力 seam Status: implemented [English](2026-06-21-subagent-capability-seam.md) | 中文 -> 完整 seam 已交付:`dsh-subagent` 接口、`dsh-subagent-mock` 测试后端与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`([其 RFC](2026-06-22-acp-subagent-backend.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)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 RFC 实现了这个 seam;上方横幅列出了已交付的内容。 +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)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 Agent Note 实现了这个 seam;上方横幅列出了已交付的内容。 决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP(Agent Client Protocol))。我们预见的传输方式: -- **进程内**:在同一个 `Context` 上创建子 `ReactLoopAgent`(最廉价,且鉴于现有 agent 工厂几乎零成本); +- **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); - 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。 @@ -20,7 +20,7 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 ### 为何不采用 bash seam 的形状 -bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。 +bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。 ## 决策 @@ -34,7 +34,6 @@ bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-s | `@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-subagent-mock` | 辅助:用于通过真实加载路径测试 seam 的脚本化提供方 | | `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 | ### 原语:异步 `start → SubagentRun` @@ -64,11 +63,11 @@ bash seam([能力 seam](../../implemented/architecture/2026-06-13-capability-s ## 测试 -seam 通过真实的 Cordis Loader/export 路径测试,这能捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的 export 形状错误。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。 +注册表与工具测试仅用包内脚本化提供方替换非确定性的子进程边界,同时运行真实的 `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 组合控制 RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责定义它们的确切语义和安全边界。 -- **阻塞父轮次。** 同步收集在子 agent 的整个持续时间内保持父 agent 的 `runStep` 打开。这对首版是可接受的;**后台 / 轮询 / 溢出语义推迟到未来的重新设计,该设计将统一 subagent 和 bash 的长时间运行工具处理**(一个 subagent 和一个长时间运行的 `bash` 后台任务面临相同的问题——「模型启动了一个慢操作,之后如何收集结果」——应共享一套机制,而非各自发明)。 +- **递归。** 如果不设限制,进程内子 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 在自己的进程中自行服务。 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 99c3a2b5e9..6132be4521 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-acp-subagent-backend.md: c02b027177ae96d75ff0d3dcac227145fe71b340 -2026-06-22-acp-subagent-backend.zh.md: 2b4db4e20fd872d21ffb72b1c2a4432c6f0103a9 +2026-06-22-acp-subagent-backend.md: a45ce5e34873249969bbe4dabb87a89d10246b3d +2026-06-22-acp-subagent-backend.zh.md: f313c11b22dc6aae6d1d1e72fce2381a572ded8d diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 2b4db4e20f..f313c11b22 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -1,4 +1,4 @@ -# RFC: ACP subagent 后端(进程外委派) +# Agent Note: ACP subagent 后端(进程外委派) Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent(智能体)作为第二个 `Agent` 运行在同一个 Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的进程外子 agent,以证明该抽象能跨越进程边界泛化。本 RFC 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。 +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 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。 ## 决策 @@ -18,11 +18,15 @@ subagent seam([seam RFC](2026-06-21-subagent-capability-seam.md))的设计 ### 最小化客户端桩 -客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam RFC 所述。 +客户端不声明任何可选能力(无 `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`。 +提供方的 `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 映射 @@ -35,6 +39,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 测试 - **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。 +- **无需密钥的 Loader 组合测试:** 仅用于测试的 cordis.yml 通过真实 Loader 启动 stdio 应用,并省略后端的 `cwd`;脚本化模型委派一次,脚本化子进程则证明它在父会话工作区中运行,且 ACP 也对外公布了该工作区,从而端到端覆盖 cwd 继承分支。 - **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。 - **快照缺口:** 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock 服务器覆盖率已具备;`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。 @@ -54,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 后续提供方 -同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam RFC 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 +同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml index 1feca9e724..4495d29887 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-25-ask-user-question.md: e28d34e7b13eb8920db1eb77f4cc66aec58c3525 -2026-06-25-ask-user-question.zh.md: 86194b4eeca2761b0af412f1510a688ef48b0422 +2026-06-25-ask-user-question.md: 97bb6c29c7c0677a9c0c2c9fe3007dd28e31adee +2026-06-25-ask-user-question.zh.md: 1ed916e39b3460d32e7e308c8ac89b8e09b763ac diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md index 86194b4eec..1ed916e39b 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md +++ b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md @@ -1,4 +1,4 @@ -# RFC: ask-user 提问能力 +# Agent Note: ask-user 提问能力 Status: implemented @@ -22,9 +22,9 @@ Provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终 ## UI 映射 -`dsh-stdio-demo` 的包内 readline 模块渲染每个问题,在下一行显示每个选项的 `description`,支持以逗号/空格分隔的数字选择 `multi_select`,接受自由格式的自定义答案,并在中止、provider dispose(资源释放)或 stdin EOF 时拒绝待处理的问题。批量请求按顺序询问,作为一个答案对象整体解析。stdio provider 通过内部队列序列化并发请求,确保同一时刻只有一个提示占用 stdin。 +`dsh-tui` 将每个问题渲染为键盘叠层,展示选项描述,支持单选、多选和自由格式自定义答案,并在中止、provider dispose(资源释放)或终端关闭时拒绝待处理的问题。批量请求和并发请求都会排队,确保同一时刻只有一个叠层占用键盘焦点。 -`dsh-acp` 为 ACP 会话提供同一 seam。它通过 bridge 的 `agent→sessionId` 反向映射将调用方 `Agent` 的 ask 请求路由出去,并为每个问题调用 ACP `unstable_createElicitation`(附带会话范围的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项的问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation,都会转为结构化的 `UserInteractionError`。 +`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 的会话路由与未来的权限门禁共享,但用户意图不同。 @@ -44,8 +44,8 @@ ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化 该功能赋予模型一个强大的暂停原语,因此 prompt 引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。 -`dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`stdio-agent` 选择性加载 seam、其 readline provider 和面向模型的工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。 +`dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`dsh-tui-demo` 选择性加载 seam、TUI provider 和面向模型的工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。 ## 测试 -单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc`)。`dsh-stdio-demo` 测试覆盖选项描述、排队请求、EOF/中止清理、无选项自由格式输入、无效选项重新提示、重复多选编号和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。 +单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案,以及模型 schema(包括移除 `value`、`recommended`、`allow_custom` 和 `desc`)。TUI 测试覆盖选项描述、排队请求、关闭/中止清理、无选项自由格式输入、无效选择、重复多选和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index cacdff7448..b44e8db0db 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-29-todo-write-tool.md: 7147440ec0cdb0b53093c371828d1987c17aabb0 -2026-06-29-todo-write-tool.zh.md: 7602ac8434963c84f089fe99a6f1e973c05e5bef +2026-06-29-todo-write-tool.md: bf5fceaafd475224914212beb460fdbc42e3dd68 +2026-06-29-todo-write-tool.zh.md: 565db37b1bf36c2aa33109870df4b36a82a57bdc diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 7602ac8434..565db37b1b 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -1,4 +1,4 @@ -# RFC: `todo_write` 工具——将模型任务列表作为事件溯源的会话状态 +# Agent Note: `todo_write` 工具——将模型任务列表作为事件溯源的会话状态 Status: implemented @@ -22,7 +22,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ### 不是 surface 事件 -`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入 surface 链表,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。) +`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入有序 surface,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。) ### Priority 仅在 ACP 边界合成 @@ -51,7 +51,7 @@ schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重 - **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。 - **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write`;`todo/write` 事件落地,第二次调用替换它。 - **`session/load` 回放**——持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。 -- **带密钥 e2e + 快照**——真实 prompt 诱导一次 `todo_write`;快照 golden 获得 `plan` 通知和日志事件。 +- **带密钥 e2e + 快照**——真实 prompt 诱导一次 `todo_write`;快照预期输出获得 `plan` 通知和日志事件。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index fe4a29a063..02ea526412 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-hook-bridges.md: c3b268bb4062c3e31acc6cdfc7641b0d1a24c47a -2026-06-30-hook-bridges.zh.md: 57ccfae2f75858254352838049516aca3668d083 +2026-06-30-hook-bridges.md: b6b0894e5551563187b1631e8c641e326fa166b0 +2026-06-30-hook-bridges.zh.md: 9b4ebfc0afaeed6e599f587d32ad2d2af43b6b68 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 57ccfae2f7..9b4ebfc0af 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -1,4 +1,4 @@ -# RFC: dsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件 +# Agent Note: dsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件 Status: implemented @@ -6,16 +6,16 @@ Status: implemented ## 问题 -harness 的扩展面是其类型化的拦截 seam(见[拦截 seam RFC](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 Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 RFC 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.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 Code(CC)和 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。各 package 的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 ## 决策 -`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: +`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 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`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。CC 钩子的 stdin 带有**尾部换行**。 -- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。工具调用的 payload 在桥接精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 +- **`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 映射 @@ -37,9 +37,9 @@ CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决 `agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()` 和 `HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `context/message.source` 为插件而非用户。 -### 添加上下文不是否决——先 delegate,再 fold +### 添加上下文不是否决——先 delegate,再 prepend -仅含上下文的钩子必须调用 `next()` 然后将其 `additionalContext` 折叠进下游决策;直接返回 allow 或 accept 会绕过后续策略监听器。Post-tool 的 block 和 accept 决策都保留已添加的上下文。Prompt allow 保留上下文,而 prompt block 丢弃上下文,因为提示词从未到达模型。只有显式的钩子 denial 或 block 才会短路 waterfall(瀑布式事件)。 +仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall 监听器中不调用 `next()` 就返回 `allow`/`accept`,会短路其后的每个 `agent/prompt-submit` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游决策。两个 seam 都携带有序的 `additionalContexts` 数组,因此桥接会在保留所有下游来源、信封和元数据字段的同时,前置加入其独立来源的条目;下游 prompt block 仍会丢弃所有上下文,因为提示词从未到达模型,而 post-tool block 语义可以显式保留上下文。Code Mode 会通过外层 `run_code` 结果转送同一数组。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:上下文钩子允许后,较晚的监听器仍能阻止提示词,且保留的 prompt 和 post-tool 上下文仍彼此分离。 ### CLAUDE_PROJECT_DIR 默认为会话工作区 @@ -55,7 +55,7 @@ Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 ` ## 推迟的兼容性缺口 -- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和 ACP/tool-bash 展示共同读取,诚实的重写是一个设计单元,而非一个字段。 +- **工具输入重写。** 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)`)。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index f745911340..760767565d 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-hook-protocol-lib.md: bd00c35ee2a1a1075ecae936af21508004f606b8 -2026-06-30-hook-protocol-lib.zh.md: 59b473e2a4e6f2dd95fa7bb7046848b036e5466a +2026-06-30-hook-protocol-lib.md: 19a69119befde99417b736edf38923ec6ac5fa7c +2026-06-30-hook-protocol-lib.zh.md: 48592b6c4af1bde9daf843692272e556a5d8b206 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 59b473e2a4..48592b6c4a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -1,4 +1,4 @@ -# RFC: dsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库 +# Agent Note: dsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库 Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Code(CC)钩子,另一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型。Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了"有意偏离"之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑。 +hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Code(CC)钩子,另一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型。Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了「有意偏离」之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑。 -本 RFC 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言专属之间的分界是本设计的重心。 +本 Agent Note 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言专属之间的分界是本设计的重心。 ## 决策 @@ -17,7 +17,7 @@ hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Cod **共享(本库):** - **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。 - **Execution** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 -- **Decode** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 +- **Decode** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **Merge** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block reason 以 `\n\n` 拼接,context/system-messages 按序累积。 - **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,declaration-merge 进 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与 turn 包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index 1079a59599..ba845de777 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-interception-seams.md: 66e1c2374936c794bd5f791547c283f0fb59fb23 -2026-06-30-interception-seams.zh.md: fb2f1528a8e72a2f6b8b07679c8631b1b0a4f2d8 +2026-06-30-interception-seams.md: af78672c8c6426aa3992355b23b191c3409dcac2 +2026-06-30-interception-seams.zh.md: 61cb0bf5404c72ef7736783ffe2015556375c873 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index fb2f1528a8..61cb0bf540 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -1,4 +1,4 @@ -# RFC: 拦截 seam——钩子编程所面对的类型化 Decision 表面 +# Agent Note: 拦截 seam——钩子编程所面对的类型化 Decision 表面 Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**"原生钩子"不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。 +harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**「原生钩子」不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。 -该表面需要为以下场景提供各自独立的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 RFC](../architecture/2026-06-30-event-domain-semantics.md) 提供了三域规则与类型化 Decision 惯用法;本 RFC 将其应用于生命周期 seam。 +该表面需要为以下场景提供各自独立的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md)提供了三域规则与类型化 Decision 惯用法;本 Agent Note 将其应用于生命周期 seam。 ## 决策 @@ -16,9 +16,9 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 **Agent 事件**(`dsh-agent`): - `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` ——waterfall,在已开启的轮次内、`user/message` 追加之前,对每条出队的排队消息触发。`allow`(可选地重写 prompt `content` 或附加 `additionalContext`)或 `block`(丢弃该 prompt;循环在其位置追加一条持久的 `prompt/blocked`——见下方调度说明)。 +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall,在轮次唯一取得所有权的排队消息追加为 `user/message` 之前触发。显式轮次 signal 位于最后的 `next` 之前;`allow` 可以重写 prompt `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会追加一条持久的 `prompt/blocked`,并拒绝这个零步骤轮次。 -**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的上下文,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。 +**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的内容和来源,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。它不是 `context/message`,因此其类型不提供持久上下文元数据。 ### 工具流水线为每个阶段赋予一种权限 @@ -26,19 +26,19 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 - **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 与核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每种结果仍会到达后策略与最终观测者。 - **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的、作用域感知的策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。 -- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前只能添加、替换或移除 `exec.signal`,并接收已规范化的抛出或未知工具结果;返回自己的有效结果则短路调度。 -- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContext`;对结果的原地 mutation 不是变换通道,因为注册表从受保护的快照加上返回的 decision 重建结果。 +- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前可以替换并恢复必需的 `exec.signal`,但不能移除它;包装层接收已规范化的抛出或未知工具结果,返回自己的有效结果则短路调度。 +- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContexts`。返回的 decision 是受支持的变换通道;waterfall 结束后,注册表会在最终观测前一次性实体化完整结果。 - **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步封闭通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。 核心调度与工具体位于规范化边界内部,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具,最终观测者看到的正是调用方收到的、会话日志可以持久化的内容。 -**`TurnEndReason.rejected`**(`dsh-session`):整批 prompt 均被 `prompt-submit` 阻止的轮次。 +**`TurnEndReason.rejected`**(`dsh-session`):取得所有权的 prompt 被 `prompt-submit` 阻止的零步骤轮次。 ### 三个承重的循环决策 -1. **在 prompt 策略之前开启轮次。** 全部被阻止的批次成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。每次否决还记录 `prompt/blocked`(含原始 prompt 和原因),因此混合批次保留被阻止的输入。允许的 `additionalContext` 注入到已开启的轮次中。 +1. **在 prompt 策略之前开启轮次。** 被阻止的 prompt 成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。否决记录 `prompt/blocked`(含原始 prompt 和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个 turn 的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。 -2. **Post-tool `additionalContext` 被缓冲,在所有 `tool/result` 之后追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但 `additionalContext` 是一条独立的 `context/message`,而单个步骤可以携带多个工具调用。如果在每个结果之后立即追加上下文,会产生 `result(c1) → context → result(c2)` 的交错,破坏工具调用/结果的邻接性。因此 `execute()` 将 `additionalContext` 暴露在其 `ToolExecutionResult` 上,循环为该步骤的每次调用缓冲上下文,仅在所有 `tool/result` 追加完毕后才以 `context/message` 形式追加。 +2. **Post-tool `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,而非下一*轮次*的 prompt(与现有的 `hasSteering` 强制继续覆盖一致)。 @@ -57,4 +57,4 @@ seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志); ## 后果 -规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../architecture.md)、各 package README、[核心拦截 decision](../../../core-data-structures/core.md#interception-decisions) 与[工具结构](../../../core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 +规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各 package README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index b06ccc29d9..d96961bf0e 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-session-store-fork-api.md: c5359a30124aabf0f9f809ffe899c6ad5fca8051 -2026-06-30-session-store-fork-api.zh.md: 62f399df41488143a66069fdd5a36ddb3e62421f +2026-06-30-session-store-fork-api.md: 4e4ea7f1d3aa3290bdb21999a292dbdfd016302e +2026-06-30-session-store-fork-api.zh.md: d21a2a233d60bd788b79eb39f0f358b8e5b90cae diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index 62f399df41..d21a2a233d 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -1,4 +1,4 @@ -# RFC: SessionStore fork API +# Agent Note: SessionStore fork API Status: implemented @@ -8,7 +8,7 @@ Status: implemented 事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。 -语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 +语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 ## 决策 @@ -40,4 +40,4 @@ class SessionStore extends Service { 公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 -v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 RFC 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。 +v1 范围仍然排除 ACP(Agent 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 持久化覆盖。 diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml index cb9c05d033..c775a6113b 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-subagent-observe-enrich.md: 1e20ddff5473a23f3ed560246fbd06f8090852ae -2026-06-30-subagent-observe-enrich.zh.md: 6bc00c9a8f1d3656fbbfc482e3e6b66020298dd9 +2026-06-30-subagent-observe-enrich.md: a07cef95630689d1ca8cacd3eb7c50e691cb304a +2026-06-30-subagent-observe-enrich.zh.md: ce8374b4f046e97c1366256c5c6626013aa8c73f diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md index 6bc00c9a8f..ce8374b4f0 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md @@ -1,4 +1,4 @@ -# RFC: Subagent 生命周期丰富化——lastAssistantMessage(仅观察) +# Agent Note: Subagent 生命周期丰富化——lastAssistantMessage(仅观察) Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -钩子子系统([拦截 seam RFC](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 产出了什么。 +钩子子系统([拦截 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 产出了什么。 -本 RFC 丰富 end 载荷。它刻意限定为**仅观察**:不改变控制流,不引入 waterfall(瀑布式事件)。影响 run 的 subagent-stop 决策(续行、改变 run 的注入)属于另一个更大的重设计,不在本 RFC 范围内。 +本 Agent Note 丰富 end 载荷。它刻意限定为**仅观察**:不改变控制流,不引入 waterfall(瀑布式事件)。影响 run 的 subagent-stop 决策(续行、改变 run 的注入)属于另一个更大的重设计,不在本 Agent Note 范围内。 ## 决策 @@ -18,14 +18,14 @@ Status: implemented ## 曾考虑的替代方案 -**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物),放在请求与两个生命周期载荷上。早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(此处没有任何逻辑解释它,唯一消费方是 CC 方言桥接层)。CC 桥接层改为直接为其 SubagentStart/Stop 的 `agent_type` matcher 填入 Claude Code 自身的默认值 `"general-purpose"`,因此本 RFC 只交付一项丰富化:`lastAssistantMessage`。 +**`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 监听方、在进程内 provider 中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam RFC](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重设计(同一个重设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 RFC 交付钩子桥接层当前所需的仅观察丰富化;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在重设计发生时的落点。 +控制流式 `subagent/end`(一个被 await 的 waterfall,返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听方、在进程内 provider 中实现 `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](../../../core-data-structures/subagent.md)(事件行文部分)与两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件触发方式与之前完全一致,end 载荷上多了一个可选字段——因此无需更新快照或 e2e 测试。 +钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器,无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../../docs/core-data-structures/subagent.md)(事件行文部分)与两个 subagent README 中;catalog 已重新生成。生产行为无变化——事件触发方式与之前完全一致,end 载荷上多了一个可选字段——因此无需更新快照或 e2e 测试。 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index bcf9d40b91..17679363d0 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-dynamic-workflows.md: e6f2ab9a4fc5f5403a7739495be7d82ebce3ec0d -2026-07-05-dynamic-workflows.zh.md: 82234c2405f972f2c42c58be6772f55eda500455 +2026-07-05-dynamic-workflows.md: 353ab56aaac2d0d7624ff35f03cc7073e50a1a7d +2026-07-05-dynamic-workflows.zh.md: aeb1f736c9674b37fcd249529e0f7dc4dac89cbb diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 82234c2405..aeb1f736c9 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -1,4 +1,4 @@ -# RFC: 动态工作流——脚本驱动的多 agent 编排 seam +# Agent Note: 动态工作流——脚本驱动的多 agent 编排 seam Status: implemented @@ -20,7 +20,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) ### seam(dsh-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](../../../core-data-structures/workflow.md)。 +`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 线程 @@ -28,7 +28,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) **为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。 -宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 静默,在此协议上保持 subagent run 契约。这些竞态算法归 [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 所有。 +宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 静默,在此协议上保持 subagent run 契约。这些竞态算法归 [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 所有。 引擎暴露一条进程内 `MessageChannel` 测试路径,因为主进程 V8 覆盖率无法观测 worker 执行。 @@ -46,7 +46,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) 输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误;没有已提交捕获的正常完成以错误结算。 -`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归 [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 所有。 +`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归 [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 所有。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index 31fb9e006f..b5882ad5ea 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-skill-system.md: f39b5f5766eadf7c0a7bfd3f887aca60f69477af -2026-07-05-skill-system.zh.md: 0aff262792a6f8a38a02475d6975510d5aae5da5 +2026-07-05-skill-system.md: 3c328adf09ab0f6ad3388a01bac3661d45c9247e +2026-07-05-skill-system.zh.md: ba30a3735b73b00afb46baa24f969fd687a759b5 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 0aff262792..ba30a3735b 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -1,4 +1,4 @@ -# RFC: Skill 系统——面向 agent 的渐进式指令披露 +# Agent Note: Skill 系统——面向 agent 的渐进式指令披露 Status: implemented @@ -26,7 +26,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 `skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 -数据结构与目录/工具契约记录在 [skills.md](../../../core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../cordis-catalog/services.md)。 +数据结构与目录/工具契约记录在 [skills.md](../../../../docs/core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/cordis-catalog/services.md)。 ## 曾考虑的替代方案 @@ -52,4 +52,4 @@ agent-core 主干包含一个 session-prefix 贡献者、一个本地提供方 ## 延后 -Fork 的 skill 上下文(`context: fork`)、直接用户/斜杠调用(`user-invocable`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。 +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)。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index fe71c00352..ec785c65c4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-approval-seam.md: dedcc2022382af7a614023e4354b77f1c15cd92f -2026-07-06-approval-seam.zh.md: 193461a2e5c3c14efe4b1565656cc38baab11fcb +2026-07-06-approval-seam.md: e28587e0185530164f300562f7dd5e57255ac515 +2026-07-06-approval-seam.zh.md: 94ffb330cd36ae88975e8ae9d579b3b4606a5c25 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 193461a2e5..94ffb330cd 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -1,4 +1,4 @@ -# RFC: 审批 seam——基于 waterfall(瀑布式事件)应答者的一次性权限决策 +# Agent Note: 审批 seam——基于 waterfall(瀑布式事件)应答者的一次性权限决策 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute` 的 `ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 RFC](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们无需各自发明独立的结果词汇、UI 路由、取消机制和审计轨迹,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。 +两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute` 的 `ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 Agent Note](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们无需各自发明独立的结果词汇、UI 路由、取消机制和审计轨迹,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。 路由问题的核心是归属:审批提示必须到达拥有发起请求的 agent(智能体)的编辑器会话(ACP(Agent Client Protocol)桥在一条连接上多路复用 N 个会话),对无人拥有的 agent(进程内 subagent、测试)失败关闭,并且不侵入没有组合 UI 的部署(headless、CI)。 @@ -27,7 +27,7 @@ Status: implemented 仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 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` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;每次 ask 在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。 +组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。 以下是该组合下的一次 ask,逐字取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,桥向拥有该会话的编辑器发出提示,用户点击 Allow once: @@ -51,45 +51,44 @@ tool/result "escalated" — this one call ran under the wider mode; the gra #### seam:机制与策略分离 -经过校验并追加 `approval/asked` 后,`request()` 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读请求,运行应答者 waterfall,与取消竞速,并将抛出异常或无效应答规范化为 `unavailable`。然后追加匹配的 `approval/decided`,以 `ApprovalRequestId` 配对。 +经过校验并成功追加 `approval/asked` 后,服务将 `approval/request` waterfall 解析为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。服务借用只读的请求标识和 signal,将中止视为 `cancelled`,把应答者失败和无效返回容纳为 `unavailable`,丢弃迟到的应答,并追加配对的 `approval/decided` 事件。提交前的审计失败会拒绝;追加后的观察者失败无法撤销权威事件。`allowed-once` 仅授权所询问的操作,而 `request()` 会拒绝打开轮次之外的调用,以保证审计对留在持久提交边界内。 -两个审计事件都必须在一个打开的轮次内;接受或预提交追加失败会拒绝该请求。提交后的观察者由会话容纳。`allowed-once` 仅授权所请求的操作,服务不保留任何授权状态。 +应答者是 `approval/request` waterfall 监听器。零监听器会一路委派至 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器随其 fiber dispose,因此卸载通道会失败关闭。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,并保留 `prepend` 给「决策或委派」门禁。 -应答者是 `approval/request` waterfall 监听器。监听器为它拥有的 agent 返回结果,否则调用 `next()`。没有应答者时默认为 `unavailable`;因此卸载 UI 即失败关闭,不会留下悬空通道。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,仅对「先决策或委派」门禁使用 `prepend`。 - -`ApprovalRequest` 携带 agent、工具名、可选的 `callId`、原因和 signal。agent 同时路由提示和审计事件。请求使用 `dsh-llm` 的 `CallId` 而不导入 `dsh-tools`,避免包循环。工具参数被省略,因为 UI 应答者附着在已渲染的调用上。 +`ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `CallId` brand 而不导入依赖本 seam 的 `dsh-tools`。工具参数仍留在 UI 通过 `callId` 引用的、已流式输出的调用上。 #### dsh-tools 中的 Ask 路由 -`ToolRegistry.execute()` 在进入拒绝路径之前,将 `ask` 发送到审批 seam。只有 `allowed-once` 才继续执行;拒绝、取消和通道不可用产生三种模型可见的不同原因。注册表按调用查找可选服务,因此服务缺失或未加载时失败关闭,不会阻塞注册表 fiber。无 agent 的执行同样失败关闭,因为无法路由或审计。 +`ToolRegistry.execute()` 在派发前解析 `ask`:`allowed-once` 继续执行,而拒绝、取消和通道不可用产生三种不同的拒绝原因。机会性消费 `ctx.get('approval')`,让缺失或未挂载的服务失败关闭而不阻塞注册表 fiber。无 agent 的执行同样失败关闭,因为它既没有审计会话,也没有 UI 所有者。 #### 每会话策略层 -seam 拥有会话策略 `'ask' | 'never'`,遵循[沙箱 RFC](2026-07-06-sandbox.md) 中的切换契约。生效的会话或配置策略在应答者之前应用:`'never'` 在 `request()` 内部直接拒绝,`'ask'` 则派发请求,无人应答时降级为 `unavailable`。提示词仅声明确定性的 `'never'`;叙述者报告切换,每个请求仍收到其审计对。 +seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 `'ask' | 'never'` 策略。生效策略由日志中记录的切换在部署默认值之上折叠而成。`'never'` 会在任何应答者运行之前,于 `request()` 内部解析为 `rejected`;`'ask'` 则派发请求,否则一路委派至 `unavailable`。提示词仅声明确定性的 `'never'`,切换叙述会被合并,每个请求仍记录审计对。 #### ACP 应答者 -ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/request_permission`,并将一次性 allow、reject、cancel 响应映射到 seam 词汇。未知选项永远不授权。外部 agent 和没有 `callId` 的请求通过 `next()` 委派;RPC 失败变为 `unavailable`。桥应答请求,但不决定哪些调用需要审批。 +ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它把 `session/request_permission` 附着到既有 `callId`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。 -应答者通过 [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) 描述的桥反向映射归属 seam 进行路由,实现了[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) 要求的每会话权限归属。 +应答者通过 [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` 是持久的仅日志事件。模型只看到发起方派生的已记录 `tool/result`。每个被接受的请求追加一条匹配的决策,包括取消和被容纳的应答者失败。 +`approval/asked` 和 `approval/decided` 是持久的仅日志事件;模型只看到从结果派生出的普通工具结果。成功完成时,每个 `asked` 都提交一个 `decided`,包括取消和被容纳的应答者失败。空闲时的请求不追加任何事件;提交前失败会拒绝,而第二次追加失败可能留下一个已经提交但未匹配的 `asked`。 #### 实体与依赖 -`dsh-user-approval` 拥有固定的派发与审计机制;`dsh-tools` 发起请求,`dsh-acp` 应答。可替换的应答者作为监听器留在其通道拥有者插件中,因此三包能力拆分只会多出一个空的实现层。沙箱执行器仍然只负责传输,静态能力授权与交互式审批保持分离。 +`dsh-user-approval` 依赖 Cordis,以及会话、agent 和带 brand 的调用契约;`dsh-tools` 与 `dsh-acp` 消费它。沙箱执行器保持独立,因为升级请求归 `dsh-tool-bash` 所有。固定的派发与审计服务仍是一个包;可替换的应答者留在各自的通道所有者中。静态能力授权和 `subagent-acp` 子侧权限应答仍是独立关注点。 ### 测试 -- **单元/集成测试:** 覆盖先到先得的委派、失败关闭默认值、畸形和抛异常的应答者、取消竞速与迟到应答丢弃、观察者失败时的审计配对、不可绕过的 `'never'`、不同的工具拒绝原因,以及 ACP 每会话路由/结果映射。 -- **快照测试:** 对沙箱升级的两个分支编排权限应答并固定 `'never'` 提示词加策略切换通知。无组合应答者时钩子产生的 ask 仍作为失败关闭拒绝被覆盖。 +单元测试固定结果、先到先得的委派、错误容纳、取消、作用域路由、审计配对、不可绕过的 `'never'` 策略、工具拒绝原因,以及通过真实脚本化桥实现的 ACP 归属/结果映射。 + +快照记录通过 `session/request_permission` 批准和拒绝沙箱升级,以及 `'never'` 提示词与策略切换通知。没有脚本化应答的权限提示会取消并失败关闭。 ## 延后 -- **`allow_always` 授权存储**:兑现持久授权意味着设计存储、作用域标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只展示一次性选项([沙箱 RFC](2026-07-06-sandbox.md) § Escalation 记录了开放的作用域问题)。 -- **有组合应答者时录制的钩子产生的 ask**:升级场景录制了人类提示的协议格式(wire format),而当前钩子 fixture(测试前置数据)固定的是无服务拒绝;二者组合的生产者/应答者路径仍由单元测试覆盖。 +- **`allow_always` 授权存储**:兑现持久授权意味着设计存储、作用域标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只展示一次性选项([沙箱 Agent Note](2026-07-06-sandbox.md) § Escalation 记录了开放的作用域问题)。 +- **通过组合应答者录制由钩子驱动的 `ask`**:人类提示协议格式已通过沙箱示例的升级分支录制。钩子矩阵中的 `hook-cc-pretool-ask` 固定无 ApprovalService 时的后备拒绝,而钩子生产者与应答者的组合仍留在单元测试层。 - **将子 agent 的审批路由到父会话**:`subagent-acp` 的子侧自动应答自己的 `permission` 请求;将其呈现给父会话的编辑器是独立的设计。 ## 曾考虑的替代方案 @@ -103,16 +102,18 @@ ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/reque ## 后果 -- 只有 `allowed-once` 才会派发被询问的操作;缺失、拒绝、取消或应答失败的路径一律拒绝。 -- 会话归属路由提示、策略和审计事件,不跨越编辑器会话。 -- 被接受的请求追加一对持久审计事件;模型只看到最终的工具结果。 -- 没有该服务的部署不产生审批提示或审计事件,在工具边界拒绝每一个 `ask`。 +实现后的契约由「测试」一节所列套件固定: + +- `allowed-once` 派发一次操作;其他所有结果都以不同原因拒绝,而 `'never'` 会在提示前拒绝。 +- 缺失、外部、无 agent、抛异常、无效或断开连接的应答路径都会失败关闭。 +- 成功的请求按精确 agent 归属路由,并追加一对可回放、对模型不可见的审计事件;空闲时和提交前失败的请求会拒绝。 +- ACP 归属把提示限制在其会话内,而没有该服务的部署不产生提示或审计事件。 代价与已接受的局限: - **两个急于决策的应答者竞争同一槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者。通过约定缓解(每个部署一个终端应答者;仅对「先决策或委派」门禁使用 `prepend`),而非事件总线不具备的优先级机制。 - **生产环境验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,沙箱升级通过自己的门禁——协议格式录制在沙箱示例的快照套件中;因此在更多部署组合它之前,seam 的真实覆盖面就是这一种组合。 -- **归属以 `Agent` 对象标识为键。** 应答者通过桥已有的 WeakMap 解析会话;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent,桥会委派并失败关闭——安全,但静默无 UI——届时需要改用 session-id 匹配。 +- **归属以 `Agent` 对象标识为键。** 应答者先在 `agent.session.id` 处解析正向会话映射记录,再要求该记录拥有精确的 agent 对象;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent,桥会委派并失败关闭——安全,但静默无 UI——届时需要另一种归属契约。 ## FAQ @@ -120,10 +121,10 @@ ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/reque - **授权能持久化吗——「始终允许」?** 不能。`allowed-once` 仅授权单次被询问的操作,服务在请求之间不存储任何内容;`allow_always` 在授权存储设计完成之前刻意不展示(§ 延后)。 - **模型看到审批的什么?** 只看到发起方从结果派生的工具结果——审计对永远不进入 transcript(文本记录)。三种非授权原因各不相同,模型可以区分人类说「不」、提示被关闭、通道缺失。 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 -- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答——无论哪种情况都恰好一对审计事件,绝不会两对。 +- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 - **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父会话的编辑器已延后(§ 延后)。 -- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次自动拒绝仍落一对审计事件。 +- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。 - **热重载或 UI 插件在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **用户在哪里看到自己在批准什么?** 在工具调用本身:提示通过 `callId` 附着在已流式输出的调用上(包含参数),并添加发起方的人类可读 `reason`;请求本身不携带参数副本。 @@ -132,7 +133,7 @@ ACP 桥找到拥有该会话的编辑器,为该 `callId` 发送 `session/reque 本设计复用或对照的仓库内先例: - `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。 -- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 RFC](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。 -- [拦截 seam RFC](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。 -- [ACP 支持 RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)——应答者路由所经过的 `WeakMap<Agent, sessionId>` 归属 seam;[多会话 RFC](../../implemented/feature/2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。 +- `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 的方式。 diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml index 53215fce5e..d232083e19 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-explicit-tool-order.md: b5f37239efc856866f08d93ab80eba91145a34db -2026-07-06-explicit-tool-order.zh.md: eca75499c2505c9cac0135ddb2ffc8cdd989b100 +2026-07-06-explicit-tool-order.md: bd6d0a04aa470ca33e618957ae1f08c1ef15fcfe +2026-07-06-explicit-tool-order.zh.md: b5600b54537319763e2bf7d54748f5c637461cbd diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md index eca75499c2..b5600b5453 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md @@ -1,4 +1,4 @@ -# RFC: 显式的模型侧工具顺序 +# Agent Note: 显式的模型侧工具顺序 Status: implemented @@ -21,9 +21,9 @@ Status: implemented `assemble()` 在 `system-prompt/assemble` waterfall(瀑布式事件)之前对提供方工具进行规范化排序,从源头消除注册顺序的差异。waterfall 从这个确定性列表开始;不变的顺序随后流入请求头、冻结的请求和重建检查,无需 loop 特有的排序逻辑。 -范围刻意收窄:本 RFC 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍然可以添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已经要求监听器是确定性的(可重建性不变式会捕获在构建与回放之间行为不一致的监听器)。 +范围刻意收窄:本 Agent Note 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍然可以添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已经要求监听器是确定性的(可重建性不变式会捕获在构建与回放之间行为不一致的监听器)。 -配置传递沿用 `persona` 的先例,`toolOrder` 与之并列:应用配置(`dsh-stdio-demo`、`dsh-acp-demo`)接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节至关重要:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链路上每个 schema 都将默认值强制为 `undefined`。 +配置传递沿用 `persona` 的先例,`toolOrder` 与之并列:TUI、Headless 和 ACP 应用配置接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节至关重要:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链路上每个 schema 都将默认值强制为 `undefined`。 ## 曾考虑的替代方案 @@ -34,13 +34,14 @@ Status: implemented - **在 `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` 在加载中途触发(与工具注册存在竞态——正是本 RFC 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设置单一执行点被判定值得接受较晚的失败时刻。 +- **启动时校验(由 `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 之前无处可观测。 -- 步骤之间的纯工具重排只能表示为 `request/header` 的 `'fallback'` 快照(按名称索引的 `ToolsDelta` 无法表达它);在稳定的权威顺序下,这种重排在实践中不再发生,因此 fallback 路径仅作为安全阀存在。 +- 快照套件中唯一固定请求头的 fixture(`text-turn`)携带新的权威工具顺序;按照固定请求头设计,其他 ACP 快照仍将大块 header 清洗为 `{{system}}`/`{{tools}}`。 +- 步骤之间的纯工具重排与其他 header 变更一样记录:一份原因是 `'change'` 的完整 `request/header` 快照。稳定的权威顺序会防止注册时序在普通路径上制造这类变化。 - `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 的转发链传递,因此部署时将其放在 app 配置中 `persona` 旁边即可;`dsh-llm` 和 agent loop 无需改动。 - `toolOrder` 中拼错或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带错误消息,`agent/error` 镜像该消息,不打开步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。 - 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的已列名称相同。这防止哨兵值变成一个歧义的真实工具,并保持「从不丢弃工具」的排序契约。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 87137ea20f..9257bc6041 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-sandbox.md: 0df97717c9166d5160189c840b108acecd3d3291 -2026-07-06-sandbox.zh.md: 164e44f6b7c84d8f279b0146cc3c74299a51d1fa +2026-07-06-sandbox.md: 738a1796b047561b861fc237154210659515bd6f +2026-07-06-sandbox.zh.md: 6843ef96413c00a12a2d1c6b70d2c5b90c6e02f7 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 164e44f6b7..6843ef9641 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -1,4 +1,4 @@ -# RFC: 子进程沙箱——约束 seam、原生 runner、升级机制与按会话模式 +# Agent Note: 子进程沙箱——约束 seam、原生 runner、升级机制与按会话模式 Status: implemented @@ -14,7 +14,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 ## 决策 -一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层杠杆:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。范围有意限定:本 RFC 命名但不设计的阶段——按会话工作区根目录、跨工具族 fs 强制、`subagent-acp` 消费方、更多环境、Windows 链——列在 § 延迟阶段,各自是后续设计,而非配置旋钮。 +一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层杠杆:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——`subagent-acp` 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。 ### 部署方式 @@ -29,7 +29,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 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 RFC) + name: '@deepseek-ai/dsh-user-approval' # the escalation gate's channel (the approval Agent Note) config: policy: ask - id: permission @@ -50,7 +50,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 #### 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`)、`SandboxPolicy`(mode + workspace root)。 +`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 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 @@ -64,29 +64,27 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 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 通过 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 交付,平台二进制由 npm 选择。该包(package)拥有路径解析、探测和 CLI flag;harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 - -FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together. +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` 复用本地进程执行,并请求 `ctx.sandbox` 包装确切的 bash argv。内核拒绝是独立于退出状态的结果事实,仅从所选包装的 stderr 方言推断。Runner 失败优先于拒绝,因为它意味着命令从未运行:前台调用抛出 `SANDBOX_UNAVAILABLE`,而已结算的后台任务为 `bash_output` 设置 `sandbox.runnerFailed`。这使损坏的约束与任务失败和强制拒绝保持区分。 +`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.sandboxMode` 是可选的按调用输入;解析后的 spec 使该字段显式。`BashExecutor.sandboxMode` 公布已挂载的执行器能否兑现它,因此只有约束组合才暴露升级。seam 接受任何显式模式;工具拥有「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。 +`BashExecRequest.sandboxPolicy` 是可选的完整按调用输入;解析后的 spec 使该字段显式。`BashExecutor.sandboxMode` 仍是公布已挂载执行器能否兑现该策略的能力事实,因此只有约束组合才暴露升级。seam 接受任何显式策略;工具拥有会话解析和「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。 -`SandboxBashExecutor.resolve()` 盖章有效模式——升级授权 > 会话覆盖 > 配置默认——使 `run()`/`start()` 读取 spec 而非配置。`danger-full-access` 分支、confine 调用和结果事实都以 spec 的模式为键,且按任务的事实 map 携带每个任务的模式及其包装事实(`notifyTaskDone()` 从 map 条目盖章):一次升级调用——前台或后台——报告它实际运行的模式,而每个邻居保持自己的。 +`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` 授权存储在该选项可被公布之前必须回答的问题;以及如何为通过 `bash_output` 延迟到达的 `run_in_background` 拒绝定义升级。 +仍未决定:持久授权超出沙箱模式之外的作用域标识是什么——确切调用、路径、命令前缀、会话或时间窗口——这是公布 `allow_always` 选项之前必须回答的问题。 #### 按会话模式:会话日志即存储 @@ -100,12 +98,12 @@ effective(session) = findLast(the session's own knob events)?.value ?? the compo ```ts interface SessionEventMap { - 'bash/sandbox-mode': { mode: 'read-only' | 'workspace-write' | 'danger-full-access' } + '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'` 门控是[批准 RFC](2026-07-06-approval-seam.md) 同一模式的另一侧。 +每个拥有者导出相同的三件套:事件声明、纯 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'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个 pre-step 递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的。 @@ -115,23 +113,19 @@ interface SessionEventMap { #### 进程内工具 -fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层面的策略:fs 意图门控按共享模式词汇决策(§ 延迟阶段,跨工具族),使 `read-only` 成为真正的边界而非仅限 bash 的近似——在此之前契约诚实地如此声明。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装。 - -FIXME: Revisit this tool-local boundary. The follow-up design needs to determine whether sandboxing becomes a global harness capability that applies uniformly to every tool, instead of expressing in-process enforcement independently at each tool seam. +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 分类、按调用事实、升级验证和结果、permission preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。 -- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定 permission 切换并拒绝未知 preset。CI 拒绝静默全跳过。 +- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、permission preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。 +- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定 permission 切换并拒绝未知 preset。CI 拒绝静默全跳过。 - **With-key:** 驱动真实模型、runner、bridge 应答器和磁盘效果通过授权和拒绝的升级;不可用的凭证或 runner 自动跳过。 -- **快照:** 固定 permission config-option 协议格式(wire format)、preset 和旋钮事件、prompt delta 和通知、以及两个脚本化的 approval 分支。快照模式以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。真实拒绝 stderr 留在平台测试中,因为其方言是 runner 特定的。 +- **快照:** 固定 permission config-option 协议格式(wire format)、preset 和旋钮事件、prompt delta 和通知、以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。 ## 延迟阶段 每个阶段在被拾起时获得完整设计,对照当时的代码验证,并在其涉及的层级带上单元测试、真实 API e2e 和快照覆盖率落地。 -- **按会话工作区根目录**——执行器的写入边界在其生命周期内保持配置固定,而每个 ACP 会话有自己的 cwd;按会话根目录一旦设计完成即搭载同一个按调用策略载体。 -- **跨工具族边界**——fs 意图门控按共享模式决策,使 `read-only`/`workspace-write` 成为 bash 之外的真正边界。 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 - **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。 @@ -157,7 +151,7 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine - **通用 `env/state` facts map 加拥有者服务**:否决。approval 和 sandbox 独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 - **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/prompt-submit`),而 pre-step 的位置以一个监听器同时服务合并的轮次入口通知和轮中即时性约束。 - **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。 -- **用专门的簿记事件追踪「上次告知」**:否决。`request/header*` fold 已记录模型看到的确切 prompt;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。 +- **用专门的簿记事件追踪「上次告知」**:否决。`request/header` fold 已记录模型看到的确切 prompt;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **ACP session modes 而非 config options**:否决。preset 已经是一个部署定义的 config-option 选择器,且 modes 计划在 ACP v2 中移除。 ## 后果 @@ -170,12 +164,13 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine - N 次空闲切换每个旋钮最多产生一个锚定事件(净零序列不锚定任何事件——客户端回显当前选择的无操作推送不记录任何内容);批准策略切换最多以一条合并通知叙述;轮中沙箱切换由下一次调用的盖章兑现。 - 恢复的会话的覆盖生效并报告给编辑器,无需特殊处理;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。 - 两个并发会话永远看不到彼此的状态、通知或配置选项。 +- 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。 - `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/pre-step`、`agent/prompt-submit` 和 ACP handler 表面。 代价与已接受的限制: - **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加 prompt 约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 -- **`read-only` 尚不是跨工具族边界。** 在 fs 意图门控按共享模式决策之前,该声明仅对 bash 成立;契约诚实地如此声明(§ 进程内工具)。 +- **`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'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 @@ -195,8 +190,8 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine - **在没有后端的平台上会发生什么——今天的 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 工具,以及传递性的钩子命令。fs/web/todo 在进程内执行,`execve` 包装对它们机械上毫无意义;它们的 `read-only` 语义随跨工具族延迟阶段到来,在此之前契约诚实地声明仅限 bash。 -- **授权的升级会持久化吗?或覆盖后台任务吗?** 都不会:授权被请求它的那次调用(前台或后台)消耗,该次调用报告它实际运行的模式,而每个邻居保持自己的。如何为通过 `bash_output` 延迟浮现的后台拒绝定义升级,留在 § 升级机制中开放。 +- **哪些工具实际在约束下运行?** 通过 `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`。 @@ -205,8 +200,8 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine 本设计复制或对比的仓库内先例: -- [能力 seam RFC](../architecture/2026-06-13-capability-seams.md)——接口/实现/消费方拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 -- `dsh-bash` 的 request/spec 拆分及其 `owner` 字段([bash 词汇目录](../../../core-data-structures/bash.md))——`sandboxMode` 搭载的按调用载体模板,以及显式 `resolve()` 默认约定。 -- [批准 seam RFC](2026-07-06-approval-seam.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。 +- [能力 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 RFC](2026-06-30-interception-seams.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 +- [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 360ea648dc..6d5a63b85f 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-mcp-client-plugin.md: 99159d6ac31fb8ef74b28a7048392d0e22124b5a -2026-07-07-mcp-client-plugin.zh.md: e4c61f173956a928c4030fc53796324b924f32d9 +2026-07-07-mcp-client-plugin.md: 90c43383882034954f33b79459fac273e60b9e0e +2026-07-07-mcp-client-plugin.zh.md: 49cce88547eb731ae5b5bf168a7a0a4a785efe94 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index e4c61f1739..49cce88547 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -1,4 +1,4 @@ -# RFC: MCP 客户端插件——连接外部 MCP 服务器并桥接其工具 +# Agent Note: MCP 客户端插件——连接外部 MCP 服务器并桥接其工具 Status: implemented @@ -8,13 +8,13 @@ Status: implemented harness 此前无法消费 MCP(Model 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()`")。基础设施已就绪,缺的是桥接插件。 +`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 RFC](../../implemented/architecture/2026-06-13-capability-seams.md))。 +单个包(package) `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 的三包拆分——可预见范围内不会有第二种 MCP 客户端实现,且约定是「不要预防性拆分」([能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md))。 ### SDK @@ -22,7 +22,7 @@ harness 此前无法消费 MCP(Model Context Protocol)生态中的工具。M ### 范围 -仅 MCP Client(不含 server 端——ACP 已承担"将 harness 暴露为 agent"的角色)。仅桥接 **Tools**——Resources 和 Prompts 延后处理(它们需要 harness 侧尚不存在的消费机制,且设计空间较大)。 +仅 MCP Client(不含 server 端——ACP 已承担「将 harness 暴露为 agent」的角色)。仅桥接 **Tools**——Resources 和 Prompts 延后处理(它们需要 harness 侧尚不存在的消费机制,且设计空间较大)。 ### 插件形态 @@ -100,7 +100,7 @@ type Config = StdioConfig | StreamableHttpConfig 2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。 3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。 4. 无 `presentCall`/`presentResult`——ACP 桥接的通用卡片兜底负责渲染。 -5. 工具在系统提示词中是透明的——除名称本身外不附加 "[via MCP]" 标注。 +5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。 ### 公开名称规范化 @@ -143,7 +143,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。 2. 映射结果: - 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 无分隔符,多块会丢失块间边界)。 - - `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md))。 + - `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[drop-image 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`。 @@ -159,7 +159,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 2. 后续模型对这些工具的调用 → `ToolNotFoundError` → `isError: true`。 3. 恢复:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。 -这与 ACP subagent 模式一致:"崩溃即终态,报告错误,清理资源,不重试。" +这与 ACP subagent 模式一致:「崩溃即终态,报告错误,清理资源,不重试。」 ## 曾考虑的替代方案 @@ -169,7 +169,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 能力 seam 三包拆分(interface / impl / consumer) -否决。可预见范围内不会有替代的 MCP 客户端实现——MCP 只有一个协议、一个 SDK。约定是"不要预防性拆分",直到出现第二种实现。 +否决。可预见范围内不会有替代的 MCP 客户端实现——MCP 只有一个协议、一个 SDK。约定是「不要预防性拆分」,直到出现第二种实现。 ### 指数退避自动重连 @@ -177,11 +177,11 @@ v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用 ### 桥接 Resources 和 Prompts -延后。Resources 需要 harness 侧的机制来决定何时注入内容(系统提示词?按需?模型触发?)。Prompts 需要 harness 尚不具备的"提示词模板"概念。两者都需要独立设计;Tools 是高价值、低风险的起点。 +延后。Resources 需要 harness 侧的机制来决定何时注入内容(系统提示词?按需?模型触发?)。Prompts 需要 harness 尚不具备的「提示词模板」概念。两者都需要独立设计;Tools 是高价值、低风险的起点。 ### 原始模型可见工具名加可选 `toolPrefix` -否决。这是最初的提案,基于"大多数 MCP 服务器已在工具名中使用语义前缀(如 `github_create_issue`)"这一前提。该前提不成立:官方 GitHub 服务器发布的是 `create_issue`,参考文件系统服务器发布 `read_file`,Sentry 发布 `search_issues`——且上述微软调查表明冲突在生态规模下很常见。冲突时再加前缀(或 warn-and-skip)还会使可用工具集取决于插件加载顺序,且添加不相关服务器时工具可能被静默重命名——在对话中途使会话历史和权限规则失效。所有被调研的多服务器 agent 产品都不使用裸名。 +否决。这是最初的提案,基于「大多数 MCP 服务器已在工具名中使用语义前缀(如 `github_create_issue`)」这一前提。该前提不成立:官方 GitHub 服务器发布的是 `create_issue`,参考文件系统服务器发布 `read_file`,Sentry 发布 `search_issues`——且上述微软调查表明冲突在生态规模下很常见。冲突时再加前缀(或 warn-and-skip)还会使可用工具集取决于插件加载顺序,且添加不相关服务器时工具可能被静默重命名——在对话中途使会话历史和权限规则失效。所有被调研的多服务器 agent 产品都不使用裸名。 ### 仅服务器命名空间(`github__create_issue`,无 `mcp__` 前缀) @@ -201,7 +201,7 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha - **单元测试**(`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(迫使每条录制的 golden 都需要带密钥重新录制),且使每次回放依赖于 spawn 外部 MCP 服务器进程——而新增渲染行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。 +- **快照**:刻意不做。MCP 工具不引入新的 transcript(文本记录)呈现面——它们以原始 `ToolDefinition` 注册,通过 ACP 桥接的通用卡片兜底渲染,该兜底已由桥接的单元测试套件固定(`packages/ui/acp/tests/stream-update.spec.ts`)。将 MCP 服务器添加到快照示例的 `cordis.yml` 会改变已固定的 `text-turn` 系统提示词 fixture(迫使每条录制的预期输出都需要带密钥重新录制),且使每次回放依赖于 spawn 外部 MCP 服务器进程——而新增渲染行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml index 02d6f23514..546f331412 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-session-prefix.md: 81868c5b0c17c03aa1ebad788c8604dd30d1ce17 -2026-07-07-session-prefix.zh.md: 7d93d8190ce51fcbdc1b67e20fbdb4f4ea8e14c8 +2026-07-07-session-prefix.md: 322413f541706244a8a9a9113c0b79693fe54ccd +2026-07-07-session-prefix.zh.md: ee5e826b18466b20675746aef17fbd67df8cf37e diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md index 7faf80cf80..322413f541 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md index 7d93d8190c..ee5e826b18 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md @@ -1,4 +1,4 @@ -# RFC: 会话前缀——派生历史之前的仅请求消息 +# Agent Note: 会话前缀——派生历史之前的仅请求消息 Status: implemented @@ -8,37 +8,36 @@ Status: implemented 插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前,harness 为这类内容提供了两个归属位置,但两者都不合适。系统提示词是一个渲染后的单一字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(`agent.inject()`、会话启动时的 `context/message`)使开场内容变为永久:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会将其以陈旧状态固化,resume 也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。 -显而易见的第三种选项——让插件在请求发出途中编辑 `messages`——被[可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md) 禁止:每个由循环构建的请求都是会话日志的纯函数,因此无论哪个通道承载开场内容,都必须精确记录它所发送的内容。缺失的是一个带有持久记录的仅请求消息通道。 +显而易见的第三种选项——让插件在请求发出途中编辑 `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 槽位之后([协议格式顺序](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 +`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`——可重建请求 RFC 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。开发不变式([dsh-invariants](../../../../packages/support/invariants/src/index.ts))对每个循环构建的请求重新计算 `messagePrefix + boundary derivation`;未记录的前缀无法到达协议格式。 -- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 产生新实例:它重新组合,任何漂移都可追溯地落在 `'resume'` header 快照上。这就是本 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()`、`tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——[拦截 seam RFC](2026-06-30-interception-seams.md)),每条都是一次性支付的持久 `context/message`,之后被前缀缓存覆盖。 -- **在压力门禁之前组合。** 组合先于实例的首次 `agent/pre-step`,且 seam 将组合值透传:`agent/pre-step` 携带 `sessionPrefix` 参数,`CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` 将其计入 token 压力估算。如果改为让门禁读取上一个实例折叠后的前缀,则在 resume 或 fork 后的实例中(贡献者可能已增长),门禁会低估压力、跳过压缩,发出超窗口的首个请求。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。被 cancel/dispose 中断的组合(中断落在 waterfall 内部)会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。 +- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 Agent Note 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。配套的 [`dsh-agent-loop/invariant`](../../../../packages/core/agent-loop/src/invariant.ts)对每个循环构建的请求重新计算 `messagePrefix + boundary derivation`;启用该贡献时,未记录的前缀无法到达协议格式。 +- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `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 压力会将精确前缀与实际 prompt、工具和已路由模型一起读取;通用 pre-step seam 不携带压缩专属参数。被 cancel/dispose 中断的组合会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。 由于组合在边界快照之前运行,组合监听器的会话追加会加入当前请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。 ## 测试 -[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了以下行为:无 header delta 时的组合一次复用、前置插入顺序、空前缀省略、不可变性,以及组合先于 pre-step;[取消测试](../../../../packages/core/agent-loop/tests/cancel.spec.ts)固定了丢弃与重新组合。会话编解码器、不变式和压缩测试覆盖 header 往返、请求重建与前缀感知的压力核算。快照归一化保留前缀计数,[固定 header 场景](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md)拥有内容,默认示例保持无前缀。无需前缀专属的 e2e 测试,因为该 seam 是确定性的且与提供方无关;带密钥的 [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) 覆盖了其缓存经济性。 +[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了以下行为:没有变更 header 时的组合一次复用、前置插入顺序、空前缀省略、不可变性、组合先于 pre-step,以及已路由 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 delta,否则没有东西将其锚定到日志;`after` 槽位位于不断增长的历史之后,其 token 在每个请求中重复支付,且其后的所有内容不可缓存。对照各替代方案衡量,当前所有更新模式都能通过持久追加更廉价地满足(支付一次,此后缓存读取),而唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。 -- **系统提示词分段**(`system-prompt/assemble`):对此类内容否决。assembly 渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带 header delta),而开场内容需要按实例冻结的语义。 +- **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:一个每请求触发的 waterfall,贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入漂移,必须记录为完整的变更 header;`after` 槽位位于不断增长的历史之后,其 token 在每个请求中重复支付,且其后的所有内容不可缓存。对照各替代方案衡量,当前所有更新模式都能通过持久追加更廉价地满足(支付一次,此后缓存读取),而唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。 +- **系统提示词分段**(`system-prompt/assemble`):对此类内容否决。assembly 渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带完整的变更 header),而开场内容需要按实例冻结的语义。 - **持久化历史开场**(会话启动时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、跨 resume 陈旧。 -- **按轮次组合而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制每次变化都产生 header delta;且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界,`'resume'` 快照已在那里可追溯地记录漂移。 -- **在首次请求时惰性组合,让压缩读取折叠后的 header**(最初合并时的形态):评审中被取代。折叠值仅从实例的第二个请求起才与活前缀匹配,因此在 resume/fork 后的实例首步,压力门禁读取的是上一个实例的前缀,可能低估压力。在首次 pre-step 之前组合并将活值透传给 seam,使估算在每一步都精确。 +- **按轮次组合而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制产生变更 header;且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界,`'resume'` 快照已在那里可追溯地记录漂移。 +- **通过 `agent/pre-step` 携带 prompt/prefix,用于临时压力估算**:否决,因为它把通用生命周期 seam 耦合到一个消费方,而且仍会遗漏更晚的请求路由和工具;步骤后的回放会从持久的已路由 header 读取请求信封的每个字段。 - **专用会话事件承载前缀**:否决。header 事件按设计就是请求的非历史记录;第二个事件会为同一事实提供第二个归属,并多出一个需要保持完整的编解码器。 ## 后果 -- `agent/pre-step` 与 `CompactService.compactIfNeeded` 携带 `sessionPrefix` 参数:每个 pre-step 监听器和压缩后端都能看到真实的按实例值(所有仓库内实现在同一个变更中更新,遵循预发布立场)。 +- `agent/pre-step` 保持通用的 `(agent, turn, step, signal)` 检查点。压缩不接收 prefix 参数;`ctx.tokenMeter` 在步骤后从规范的已路由 header 折叠前缀。 - 贡献者的内容在会话中途变化时,直到下一个实例才会被重新读取——这是设计意图。需要会话中途目录更新的部署,应将变更通知路由到仅追加历史通道,支付一条持久 `context/message`。 - 被放弃的 `after` 槽位意味着请求尾部附近没有仅请求通道;仓库中没有任何功能需要它,且恢复它会重新引入本设计旨在避免的每步重复支付成本。 -- `request/header-delta` 的 `messagePrefix` 分支(整数组替换,空数组编码向缺失的过渡)为编解码器完整性而存在;循环从不触发它,因为缓存的前缀在实例内不可变。 - 空组合即为规范缺失:无贡献者的部署不记录额外的 header 字节,其请求就是裸派生。 diff --git a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml index 0daf9b2490..48e142e853 100644 --- a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-repeat-tool-guard.md: e422ae70f61b6c77bf6517bc5eb840afc171d82c -2026-07-08-repeat-tool-guard.zh.md: eedb36f448bbca220e023a7609e1d9666fadb8e9 +2026-07-08-repeat-tool-guard.md: 67ec29c6c9fa38bf1d5935c469f3f71b1119dc3a +2026-07-08-repeat-tool-guard.zh.md: 597a51c3a9b160f0447d22e8197917f59cd9ce11 diff --git a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md index eedb36f448..597a51c3a9 100644 --- a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md @@ -1,4 +1,4 @@ -# RFC: 重复工具调用守卫插件 +# Agent Note: 重复工具调用守卫插件 Status: implemented @@ -8,17 +8,16 @@ Status: implemented 模型陷入循环时,会以字节级相同的参数反复发起同一个工具调用——重新运行一条失败的 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 RFC](2026-06-30-interception-seams.md) 赋予 `tools/post-execute` 一种经过认可的方式,将面向模型的上下文附加到已完成的调用上;循环缓冲并注入该上下文,同时保持调用/结果的邻接关系;注入的上下文是一条已记录的 `context/message`——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺少的只是插件本身。 +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 RFC](2026-06-29-todo-write-tool.md) 发布了 `todo/tool-todo`)。它注册三个监听器,所有状态保存在以 `AgentId` 为键的插件局部 map 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent(智能体)的调用(subagent 运行在同一个 context 上),因此按 agent 分键是正确性要求,而非锦上添花。 +插件为 `@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` 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent(智能体)的调用(subagent 运行在同一个 context 上),因此按 agent 分键是正确性要求,而非锦上添花;弱对象键还使得纯清理用途的 disposal 监听器不再必要。 -- **`tools/post-execute`(waterfall)**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要它,仅因为其 `tool_call`/`tool_result` 钩子是分开的事件)。它始终通过 `next()` 委托,当命中阈值时,将提醒折叠到下游决策的 `additionalContext` 上——这正是[钩子桥接](2026-06-30-hook-bridges.md)已采用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非 `tools/pre-execute`,因为 post-execute 也会为被拒绝的调用触发(`ToolRegistry.execute` 将 deny 路由到同一条流水线),而模型反复敲击一个被拒绝的调用恰恰是值得打破的循环。 +- **`tools/post-execute`(waterfall)**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending map(pi 扩展需要它,仅因为其 `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 的链。用户介入改变了上下文;跨越介入的重复不是循环。 -- **`agent/status`(emit)**——在 `disposed` 时丢弃该 agent 的状态,使 map 在 harness 生命周期内有界。 ### 检测语义 @@ -27,11 +26,11 @@ harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 s 两条刻意的规则,均记录在[包 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()` 的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的 `AgentId`。 +- **没有 agent 的调用被忽略。** 直接调用 `ctx.tools.execute()` 的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的存活 agent 对象。 ### 提醒投递 -提醒使用带插件 source 的 `additionalContext`,保留原始 `tool/result`。第一个阈值发出简短提示;后续阈值包含工具名、计数和有界的参数预览,而比较仍使用完整的规范化字符串。已有的下游上下文在守卫的 source 下拼接,因为 `HookContext` 只支持一个 source。 +提醒作为独立条目搭载在 `additionalContexts` 上(source 为 `{kind: 'plugin', plugin: 'repeat-tool-guard'}`——依照 `HookContext`,该标签承载语义),绝不替换 `content`:`tool/result` 事件仍是工具自身的审计输出,循环则在步骤结果之后把缓冲的上下文追加为 `context/message`,session 将其渲染为带标签的合成 user 信封,并由派生历史回放。阈值逐级升级:第一个配置阈值获得简短的「你正在重复自己,请分析先前结果」提示;后续各阈值获得详细形式,包含工具、重复计数和规范参数(在头部截断到 `argumentsPreviewChars`,默认 500——循环中的 `write` 级 payload 不得无界地进入下一次请求;链键始终比较完整规范字符串),并说明这些调用没有取得进展。pi 原版把温和文本硬编码为字面计数 3;本守卫以 `thresholds[0]` 为键,修复了移植中的这一 bug。下游钩子桥贡献仍是独立数组条目,因此两个插件都保留各自的 source、信封与元数据。 ### 配置 @@ -55,7 +54,7 @@ harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 s ## 曾考虑的替代方案 -- **将提醒追加到工具结果中**(以替换 `content` 的方式 `accept`——pi 扩展的机制,它修补结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的 `tool/result` 对工具实际返回的内容撒谎,而 `additionalContext` 的存在正是作为 post-execute 评注的独立认可通道,循环级缓冲保持了调用/结果的邻接关系。 +- **将提醒追加到工具结果中**(以替换 `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 原生的形式,且没有逐调用的子进程开销。 @@ -67,7 +66,8 @@ harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 s - 提醒在设计上是建议性的:有意重复相同调用的幂等轮询模式仍会在超过阈值后收到提示,减压阀是配置(`thresholds`、`exclude`)加上明确允许「在已收集足够证据时结束」的提醒文本。每次触发在下一次请求中增加提醒 token 的开销;阈值限制了触发频率。 - 链状态仅存于内存:从持久化恢复的会话以全新的链开始,因此跨越恢复的循环比实时循环更晚收到提醒——可以接受,守卫是启发式提示而非已记录的不变式,持久化计数器状态带来的收益不值得其复杂度。 -- 当多个 post-execute 生产者在同一次调用上附加上下文时,折叠在守卫的 `source` 下拼接;插件间的顺序遵循监听器注册顺序。该 seam 无法表示混合来源——这是继承自 `HookContext` 的限制,不归本插件所有。 +- 当多个 post-execute 生产者在同一次调用上附加上下文时,每项贡献保持为独立的 `HookContext`;顺序遵循 waterfall 嵌套关系,每个条目保留自己的溯源信息。 +- 实现快照层时暴露了 suite kit 的一项隐藏假设:fixture guard 把「撰写的模型场景」等同于「由 override 驱动」。`Scenario` 表现在携带显式的 `overridden` 标志,并且 sidecar 是否存在会以双向方式与其核对(未注册的游离 sidecar 会静默替换派生脚本)——suite kit 比本插件出现前更严格。 ## 延后事项 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 45ea89f234..f7e6c2af07 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-self-referential-cordis-toolset.md: c79bc09dde85d79adc5435f3e08c702cab1369d9 -2026-07-08-self-referential-cordis-toolset.zh.md: e6cada0530652cfb40e7ea1edadf1b12e0f07e77 +2026-07-08-self-referential-cordis-toolset.md: 73d394c24100af8f506cea5b8f43b96d260de160 +2026-07-08-self-referential-cordis-toolset.zh.md: ba0b6ae761ba1e560075177f5867ee634dcbfee8 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index e6cada0530..ba0b6ae761 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -1,4 +1,4 @@ -# RFC: 自引用 cordis 工具集 +# Agent Note: 自引用 cordis 工具集 Status: implemented @@ -20,11 +20,11 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 | 工具 | 契约 | |---|---| -| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。从不产生变更。 | +| `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 事件及其分发模式和签名)。面向模型的工具描述携带了模型在调用时所需的操作规则;[生成的工具目录](../../../tool-catalog.md)是其完整呈现。 +`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)是其完整呈现。 ### 沙箱语义 @@ -46,15 +46,15 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 生成的 API 目录 -`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、事件模式、引用的类型声明以及继承的 context 接口面。有歧义的类型名被省略,过大的声明被标记为截断。 +`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、原始服务方法与事件 JSDoc、事件模式、引用的类型声明以及继承的 context 接口面。有歧义的类型名被省略,过大的声明被标记为截断。 -新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此修改了公开签名的 JSDoc 变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:有目录条目的活跃服务渲染摘要 + 签名,没有目录条目的活跃服务(挂载提供的)渲染名称 + 所属 fiber,有目录条目但无活跃提供方的服务简要列出,引用的类型形状随后附上。 +新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此 JSDoc 或公开签名变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:宽泛报告把有目录条目的活跃服务渲染为摘要 + 签名,把没有目录条目的活跃服务(挂载提供的)渲染为名称 + 所属 fiber,简要列出有目录条目但无活跃提供方的服务,再附上引用的类型形状。精确名称报告渲染一个活跃服务或事件,并把原始 JSDoc 紧靠在每个签名之前;让该细节按需出现,避免探索性列表承担其 token 成本。 ### 配置、渲染与可观测性 -该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。 +该插件暴露一个配置字段,由 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 delta 记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复一个持久化的会话会重建对话,但不会重新挂载插件。 +「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它们),而挂载引起的工具集变化由循环在 schema 在步骤间发生变化时发出的完整变更 request header 记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复一个持久化的会话会重建对话,但不会重新挂载插件。 ## 曾考虑的替代方案 @@ -73,10 +73,10 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 **在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一份手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节且没有门禁约束这种漂移,而生成产物的新鲜度由文档使用的同一套 AST 检查。 -**新增 `cordis/mount` 会话事件。** 一个持久的溯源事件记录每次挂载(源码、名称)有明确先例(`hook/invoked`、`compact/start`)。v1 中予以否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为 request-header delta 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源从工具调用中分离出来,日后仍可添加。 +**新增 `cordis/mount` 会话事件。** 一个持久的溯源事件记录每次挂载(源码、名称)有明确先例(`hook/invoked`、`compact/start`)。v1 中予以否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为完整的变更 request header 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源从工具调用中分离出来,日后仍可添加。 **加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始 context,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的 context 逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。 ## 后果 -该工具集是刻意的显式启用设计,具有完全特权的 `ctx`,因此部署方采用它的意识程度应与 bash 工具相当。以下几个事实由工具描述直接告知模型:一个 waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此一个挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会导致死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。 +该工具集是刻意的显式启用设计,具有完全特权的 `ctx`,因此部署方采用它的意识程度应与 bash 工具相当。以下几个事实由工具描述直接告知模型:一个 waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此一个挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../../docs/cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会导致死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。 diff --git a/.agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml index 30fa8ab09e..3100e8abae 100644 --- a/.agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-session-query-service.md: 8c990f4407d91c0be1fc01046e34aa3e76076ed4 -2026-07-10-session-query-service.zh.md: ea0d2d52668fb40b97621884f635cb03dea6f663 +2026-07-10-session-query-service.md: b6581d0bfcdce912a2983f3ebf0c90a2b4c3cf14 +2026-07-10-session-query-service.zh.md: 9d42caa33c860170771fd678fd69d4c43bcf6160 diff --git a/.agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md b/.agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md index ea0d2d5266..9d42caa33c 100644 --- a/.agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md @@ -1,4 +1,4 @@ -# RFC: 精确会话查询服务 +# Agent Note: 精确会话查询服务 Status: implemented @@ -6,13 +6,13 @@ Status: implemented ## 问题 -会话历史存在于两处:当前的 `SessionStore` 对象与可选的持久化后端。需要精确检查的消费方若无统一服务,就不得不各自重复实现活跃/持久化优先级判定、持久化生命周期处理、原始事件的 surface 分类以及防御性克隆。在检查点之间,持久化状态可能落后于活跃日志,因此仅靠持久化并非当前状态的可靠来源。 +会话历史存在于两处:当前的 `SessionStore` 对象与可选的持久化后端。需要精确检查的消费方若无统一服务,就不得不各自重复实现活跃/持久化优先级判定、持久化生命周期处理、原始事件的 surface 分类、关系追踪以及防御性克隆。在检查点之间,持久化状态可能落后于活跃日志,因此仅靠持久化并非当前状态的可靠来源。 全文搜索与此相关,但规模大得多。在真实后端尚不存在时就设计提供方注册、提取、同步、失效、排序和游标契约,会产生两个投机性的状态机:一个在接口服务中,另一个在最终的数据库包(package)中。 ## 决策 -`@deepseek-ai/dsh-session-query` 拥有 `ctx.sessionQuery`,这是一个小型的、受信任的精确读取服务,面向单一逻辑语料库。它暴露 `listSessions()`、`listEvents(sessionId)` 和有界的 `readEvent(request)`。它不暴露过滤器、血缘或溯源遍历、文本提取器、搜索请求、提供方注册或派生索引同步。 +`@deepseek-ai/dsh-session-query` 拥有 `ctx.sessionQuery`,这是一个小型的、受信任的精确检查服务,面向单一逻辑语料库。它暴露 `listSessions()`、`listEvents(sessionId)`、有界的 `readEvent(request)`、`traceSession(sessionId)` 和 `traceEvent(request)`。它不暴露过滤器、文本提取器、搜索请求、提供方注册或派生索引同步。独立的[追踪决策](2026-07-13-session-query-tracing.md)拥有血缘与事件关系语义。 该服务动态观察可选的 `ctx.sessionPersistence` 绑定,但不保留持久化缓存或失效监听器。每次跨语料库列表操作向活跃后端请求权威元数据,然后叠加一份新鲜的活跃 store 列表。id 匹配的条目合并为一条 `SessionRecord`:活跃 header 优先,`live`/`persisted` 各自独立报告来源可用性。不可变 header 不一致时产生 `SESSION_QUERY_SOURCE_CONFLICT`。 @@ -20,13 +20,13 @@ Status: implemented ## Surface 语义 -`dsh-session` 导出 `foldSurface(events)`,`SurfaceManager` 使用相同的转换函数维护其增量缓存。fold 返回分离的当前节点以及每次替换实际移除的 seq。`listEvents()` 利用该结果将每个原始事件分类为 `current`、`shadowed` 或 `log-only`,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。 +`dsh-session` 导出 `foldSurface(events)`,`SurfaceManager` 使用相同的转换函数维护其增量缓存。fold 返回分离的当前事件 seq 以及每次替换实际移除的 seq。`listEvents()` 和 `traceEvent()` 利用该结果为每个原始事件分类,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。 `readEvent()` 返回完整的目标加上按连续 seq 排列的原始相邻事件。`before` 和 `after` 默认为零,各自受 `readWindowMax`(默认 50)约束。结果携带克隆的 `SessionHeader` 而非来源可用性记录,因为判断活跃目标的 persisted 标志会违反「活跃精确读取不依赖持久化健康状态」这一保证。 ## 安全边界 -该服务是上下文级别的受信任基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话范围。本阶段不添加面向模型的工具,也不改变 transcript(文本记录)或快照的 surface。 +该服务是上下文级别的受信任基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话范围。该服务不添加面向模型的工具,也不改变 transcript(文本记录)或快照的 surface。 ## 曾考虑的替代方案 @@ -34,10 +34,9 @@ Status: implemented - **仅查询持久化**:否决。检查点可能落后于当前活跃日志。 - **缓存持久化元数据并监听写入/删除**:否决。精确读取可以直接询问权威来源,而缓存失效在规模尚未要求时就引入了生命周期与并发状态。 - **现在就定义提供方无关的搜索协议**:否决。目前没有提供方消费它。第一个 SQLite FTS 包应自行拥有一个协调/事务状态机;只有当第二个实现证明了边界时,才提取更小的共享 seam。 -- **在第一阶段就包含血缘、溯源和通用过滤器**:否决。当前没有消费方需要它们,且规范日志足以在日后有证据时再行添加。 ## 后果 -第一阶段只有一个来源解析状态变量:当前挂载的持久化服务。没有提供方队列、指纹、提取器注册表、观察代次或派生索引更新。精确读取在纯活跃部署中仍然可用,在持久化存在时具有确定性。 +该服务只有一个来源解析状态变量:当前挂载的持久化服务。没有提供方队列、指纹、提取器注册表、观察代次或派生索引更新。精确读取和事件追踪在纯活跃部署中仍然可用,在持久化存在时具有确定性。 -跨语料库列表与持久化精确读取在每次调用时执行后端 I/O。这是有意为之:正确性来自当前权威状态,面向规模的搜索属于第二阶段的数据库。在该包定义并实现其完整契约之前,全文搜索不可用。 +跨语料库列表、血缘追踪和持久化事件操作在每次调用时执行后端 I/O。这是有意为之:正确性来自当前权威状态,面向规模的搜索属于提议中的数据库包。在该包定义并实现其完整契约之前,全文搜索不可用。 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 65e180d55d..276ea90554 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-subagent-persona-tool-filter-and-depth.md: c88695d4444008bff69fcb10e3cf33c7b8820b9b -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 1efbbce57274c11b2811ebe844c9cd80f81f407f +2026-07-12-subagent-persona-tool-filter-and-depth.md: c690f4701a54272205eedf719fbfe0863bac566c +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: ec18becb9c3f446138e7b3573582f3ff2fbb891a diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index 1efbbce572..ec18becb9c 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -1,4 +1,4 @@ -# RFC: 配置 subagent 的人设、工具可见性与深度 +# Agent Note: 配置 subagent 的人设、工具可见性与深度 Status: implemented @@ -51,9 +51,11 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 深度限制独立于工具可见性来约束递归委派。顶层 agent 深度为零;进程内子 agent 的深度为其父级已验证深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。 -每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。省略上限时,此机制不约束深度。 +有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在 session header 中,resume 会恢复该 header,因此重启无法降低递归计数。 -部署可以组合深度与过滤。例如,可以在深度一时保持委派工具可见但设置 `maxDepth: 1`,或在子 agent 中完全 deny 委派工具。两种选择都不改变提供方的对话历史行为。 +每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `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 委派工具。两种选择都不改变提供方的对话历史行为。 ### 能力门控保持提供方诚实 @@ -85,10 +87,10 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma **仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个 prompt 声称不存在的工具。改为由一个解析器同时管控呈现和执行。 -**仅用工具过滤来阻止递归。** 移除委派工具有用但依赖特定提供方,且不保护直接服务调用方或替代委派工具。绝对深度是独立的结构性约束。 +**把深度上限编码为自动工具过滤器。** 创建时过滤器会快照一个可能依赖运行时状态的决策,只影响一个已配置工具名,且不保护直接服务调用方或替代委派工具。提供方改为在每次启动时强制绝对上限。 ## 后果 贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 -代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升级问题。 +代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升级问题。 diff --git a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml index 1e877bfba3..9a7213863f 100644 --- a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-doc-sync-enforcement.md: cb238291f9ff5ba5d6333cdf2fe75a43fe3e8eea -2026-06-11-doc-sync-enforcement.zh.md: a443cfcd6f9bd17ae1512683ff8cd82411fb8590 +2026-06-11-doc-sync-enforcement.md: 375059312c312dff7b5ddcb95ea5b82ac8cd4d06 +2026-06-11-doc-sync-enforcement.zh.md: 7d0e812c7a4b0ec64113cd272b2fcbdfb6055f18 diff --git a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md index a443cfcd6f..7d0e812c7a 100644 --- a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md @@ -1,4 +1,4 @@ -# RFC: Doc-sync 强制 +# Agent Note: Doc-sync 强制 Status: implemented @@ -13,20 +13,20 @@ AGENTS.md 承诺文档与代码严格同步,但这一承诺此前仅靠人眼 两道门禁,沿用既有的 `scripts/` 风格(tsx ESM,每个脚本一项职责): 1. **`doc-typecheck`** 从 `README.md`、`docs/**` 和 `packages/*/README.md` 中提取所有 ` ```ts ` 围栏代码块,写入一个继承根 `tsconfig.json` 的临时项目,然后用 `tsc -b` 编译。临时项目复用源码的 `paths` 映射和根 project references,因此文档示例能看到源码,而 vendor 代码仍在其自身的 tsconfig 设置下被检查。刻意作为草图的代码块可通过显式的 ` ```ts ignore-check ` 信息字符串来 opt-out;脚本会报告 opt-out 比例,超过一半即失败,防止该豁免机制悄然成为常态。 -2. **`verify-event-taxonomy`** 从 `packages/*/src` 中的 `interface Events` 块和 `docs/architecture.md` 中的分类体系表分别提取事件名称,断言两个集合完全一致。只校验,不生成:表格保留手写的 Mode/Purpose 列,仅检查名称集合。(落地此门禁时发现了表格遗漏的三个事件:`tools/change`、`llm/adapter-change`、`system-prompt/change`。)**已被取代**:由[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)取代。此门禁及其 `architecture.md` 表格已退役,取而代之的是完全生成的 `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` 及其 `verify-cordis-catalog` 新鲜度门禁。本 RFC 中的其他门禁(`doc-typecheck` 以及下文修订中的 `verify-md-wrap`)不受影响。 +2. **`verify-event-taxonomy`** 从 `packages/*/src` 中的 `interface Events` 块和 `docs/architecture.md` 中的分类体系表分别提取事件名称,断言两个集合完全一致。只校验,不生成:表格保留手写的 Mode/Purpose 列,仅检查名称集合。(落地此门禁时发现了表格遗漏的三个事件:`tools/change`、`llm/adapter-change`、`system-prompt/change`。)**已被取代**:由[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)取代。此门禁及其 `architecture.md` 表格已退役,取而代之的是完全生成的 `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` 及其 `verify-cordis-catalog` 新鲜度门禁。本 Agent Note(agent 决策记录)中的其他门禁(`doc-typecheck` 以及下文修订中的 `verify-md-wrap`)不受影响。 -两者通过一个共享的 `doc-sync`(文档同步门禁)package.json 脚本运行,lefthook pre-push 钩子和 CI 都调用它([机械质量门禁](2026-06-11-quality-gates.md):钩子与 CI 调用相同脚本,因此门禁在推送前就在本地触发,而非仅在推送后)。它们在 `pnpm run typecheck` 之后运行,后者校验 doc-typecheck 所引用的 package/vendor 构建图。 +两者都通过 package.json 中共享的 `doc-sync` 脚本运行;贡献者在相关文档变更中调用它,CI 则执行完整检查。[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)决策使这类按变更面选择的工作不进入 commit 和 push 钩子。 **修订(2026-06-17):** 第三道门禁 **`verify-md-wrap`** 随后被纳入 `doc-sync`。它使用 `mdast-util-from-markdown` + GFM 解析范围内的每个 Markdown 文件(`README.md`、`docs/**`、`packages/*/README.md`,加上 `AGENTS.md` / `packages/AGENTS.md`),如果任何 `paragraph` 节点跨越多个源码行则失败,从而强制执行 docs/AGENTS.md 中「一个段落一个物理行」的写作规则。同样遵循只校验不生成的原则:它报告硬换行但从不重写,因此不会引入格式化噪音。`doc-sync` 现在包含三道门禁。 ## 曾考虑的替代方案 -- **API-extractor 金标报告**([已推迟的提案](../../proposed/process/2026-06-11-api-extractor-reports.md)):有意推迟。对于评审者已能直接看到源码 diff 的内部 monorepo 而言价值有限,且依赖重、配置繁琐。 +- **API-extractor 基准报告**([已推迟的提案](../../proposed/process/2026-06-11-api-extractor-reports.md)):有意推迟。对于评审者已能直接看到源码 diff 的内部 monorepo 而言价值有限,且依赖重、配置繁琐。 - **从源码生成分类体系表**而非仅校验名称:否决,机制比问题本身更重;表格保留了手写的 Mode/Purpose 列,直到[生成式 Cordis 目录](2026-06-20-generated-cordis-catalog.md)完全取代了这项检查。 ## 后果 -- 可检查类别的文档漂移现在会让 pre-push 钩子和 CI 失败,而非等待评审者发现。这是「机械门禁优于行文约定」原则的一个实例。 +- 可检查类别中的文档漂移会直接使 `doc-sync` 和 CI 失败,而不是等评审人发现。这是「机械门禁优于行文规范」原则的具体应用。 - 让文档代码片段可编译需要少量 stub import/`declare`;`ignore-check` 比例必须保持低位,否则门禁形同虚设(比例守卫强制执行此约束)。 - 分类体系检查仅限名称——Mode 或 Purpose 列的错误仍需人工评审。 - 如果 package 未来对外发布,API 报告方案仍可重新考虑。 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml index be4c70583e..0e84de0e10 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-quality-gates.md: 2d6b6815e80a728cb61a86e9f7a488340fe06fc9 -2026-06-11-quality-gates.zh.md: f5088811f8562f5103740d39b48bd14c6d1da00c +2026-06-11-quality-gates.md: e1af110387936d644208dc1829fde4a4fdf8a3f9 +2026-06-11-quality-gates.zh.md: eaae458e3eac6a45e0c16ec0b8fb950aa1b70619 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 5e1db16e52..e1af110387 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-11-quality-gates.zh.md) + The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path. ## Problem diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md index f5088811f8..eaae458e3e 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md @@ -1,28 +1,30 @@ -# RFC: 以机械质量门禁取代行文约定 +# Agent Note: 以机械质量门禁取代行文约定 Status: implemented [English](2026-06-11-quality-gates.md) | 中文 +本记录中的钩子/CI 对称设计已由[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)取代;CI 仍是执行完整检查的路径。 + ## 问题 本代码库主要由 coding agent(智能体)开发。相比行文约定,agent 遵守强制门禁的可靠性远高得多;而当劳动由 agent 承担时,「工作量大」不构成成本论据。早期证据:未通过类型检查的测试被提交(vitest 不做类型检查),仅在评审中才被发现。 ## 决策 -AGENTS.md 中的每一条承诺都对应一个以非零退出码表示失败的命令,通过 git 钩子和 CI 调用同一套 package.json 脚本来执行: +每条可机械检查的 AGENTS.md 承诺都有一个以非零状态退出的命令。CI 执行完整集合,而 Git 钩子将延迟预算留给可低成本发现的本地缺陷: - 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而 package/vendor 代码保持在各自 project-reference 边界之后。 - ESLint strict-type-checked + @stylistic(作为强制执行的统一代码风格),包括文件内重复逻辑检查;vendor 代码排除在外。 - jscpd 检测 package 生产 TypeScript 与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 - `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 - knip(死代码/依赖)、publint(包(package)正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 -- lefthook pre-commit(lint 暂存文件、类型检查、vendor manifest(元数据清单)守卫)和 pre-push(测试、hygiene);CI 在 Node 22.19/24/26 上运行完整矩阵,外加一个驱动 echo-agent 端到端的演示冒烟测试。 +- lefthook pre-commit 修复已暂存文件的 lint 问题、拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 ## 后果 -- 约定在 agent 更替中得以存续;违规在本地快速失败。 +- 约定不会因 agent 更替而失效;可低成本发现的 commit/push 缺陷在本地失败,其余完整规则违规在 CI 中失败。 - 门禁本身也是需要维护的代码;配置变更与其他变更一样需要评审。 - 100% 覆盖率的压力可能催生无断言的测试——变异测试是计划中的对策(见[变异测试提案](../../proposed/testing/2026-06-11-mutation-testing.md))。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml index bb496d19e3..85dd3e2652 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-tsdown-over-dumble.md: b1ce354b9b2baa042d8aa1be2a8de171ed4adfef -2026-06-11-tsdown-over-dumble.zh.md: 4bdd9939901ef376f2f6ccded609b644a4698495 +2026-06-11-tsdown-over-dumble.md: 5d7593d689e2404bede0fa51b41c5d8eec397a03 +2026-06-11-tsdown-over-dumble.zh.md: 080ee4c3fb9894049716fd5aec3ddd7b3d62d547 diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md index 4bdd993990..080ee4c3fb 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md @@ -1,4 +1,4 @@ -# RFC: 使用 tsdown 替代 dumble 进行 JS 打包 +# Agent Note: 使用 tsdown 替代 dumble 进行 JS 打包 Status: implemented @@ -15,7 +15,7 @@ Status: implemented 用 **tsdown**(基于 rolldown,每周约 250 万次下载,VoidZero 支持,活跃发布)替代 dumble: - 根目录 `tsdown.config.ts`,配置 `workspace: ['vendor/*', 'packages/*/*']`(显式 glob 将打包范围限定在 vendor 的 Cordis 与 TypeScript 包目录树内;`workspace: true` 还会发现示例 manifest 和不需要打包的 workspace 成员)。 -- 共享形态:入口 `lib/types/index.js`,`outDir: 'lib'`,ESM,`platform: node`,`target: es2024`,`fixedExtension: false`(为 `"type": "module"` 的包保持 `.js` 扩展名),`dts: false`(声明文件由 tsc -b 负责),`clean: false`(lib/ 同时存放 TSC 的 `lib/types` 中间产物树)。入口最初是 `src/index.ts`;[TSC 优先构建 RFC](2026-06-17-ts-build-config.md) 后来将 tsdown 改为打包 TSC 输出的 JS,使 TypeScript 转换行为统一来自一个编译器。 +- 共享形状:入口为 `lib/types/index.js`,`outDir: 'lib'`,ESM,`platform: node`,`target: es2024`,`fixedExtension: false`(为 `"type": "module"` 包保留 `.js`),`dts: false`(声明归 tsc -b 所有),`clean: false`(lib/ 还保存 TSC 的 `lib/types` 中间树)。入口最初是 `src/index.ts`;[TSC 优先构建 Agent Note(agent 决策记录)](2026-06-17-ts-build-config.md)随后将 tsdown 改为打包 TSC 输出的 JS,使 TypeScript 转换行为统一由一个编译器提供。 - vendor/ 中有两个按包覆盖的配置(属于我们自己的修改,与重新生成的 tsconfig 类似;记录在 vendor/README.md 中):schemastery(通过 `outExtensions` 输出双格式 `.mjs`/`.cjs`)、logger-console(两次单入口 pass,使共享基类被内联到每个入口而非生成哈希命名的 chunk,与上游发布形态一致)。 - `scripts/build.ts` 删除;`pnpm run build` = `tsc -b tsconfig.build.json && tsdown`。 @@ -27,4 +27,4 @@ Status: implemented ## 后果 -运行时打包产物仍遵循 dumble 时代的公开入口形态(`lib/index.js`,以及按包特定的变体,如 `schemastery` 的 `lib/index.mjs`/`lib/index.cjs` 和 `logger-console` 的 `lib/browser.js`);声明文件现在位于 `lib/types` 下,见 [TSC 优先构建 RFC](2026-06-17-ts-build-config.md)。外部依赖仍来自各包的 dependencies/peerDependencies。我们放弃了 dumble 的 exports 字段推断功能:新增的非默认形态的包需要编写按包的 `tsdown.config.ts`,而不能仅靠 package.json 字段。未来可选方向:如果 `tsc -b` 成为瓶颈,tsdown 还可以接管声明文件打包(isolatedDeclarations);那将是一个新的 RFC。 +运行时 bundle 输出仍沿用 dumble 时代的公开入口形状(`lib/index.js`,以及包特有的变体,例如 `schemastery` 的 `lib/index.mjs`/`lib/index.cjs` 与 `logger-console` 的 `lib/browser.js`);根据 [TSC 优先构建 Agent Note](2026-06-17-ts-build-config.md),声明现位于 `lib/types` 下。External 仍来自各包的 dependencies/peerDependencies。我们放弃了 dumble 的 exports 字段推断:采用非默认形状的新包需要逐包提供 `tsdown.config.ts`,不能只依赖 package.json 字段。未来如果 `tsc -b` 成为瓶颈,tsdown 也可以接管声明打包(isolatedDeclarations);这需要另写一份 Agent Note。 diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml index 339d733f07..0e0c6693a2 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-vendor-cordis-as-source.md: 6e1af616785411a20496334fb35e0c7a3bc41b43 -2026-06-11-vendor-cordis-as-source.zh.md: bb9413fbba34e9a7040fd2e0a1bbcff1ba2345ff +2026-06-11-vendor-cordis-as-source.md: ae6f5438c5817c61a549d9edb2041d538fbcebe6 +2026-06-11-vendor-cordis-as-source.zh.md: 8d6f0e39d53e1c85eaaa50c4c4bf1d9ef648d953 diff --git a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md index bb9413fbba..8d6f0e39d5 100644 --- a/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md @@ -1,4 +1,4 @@ -# RFC: 将 Cordis 以源码形式收录,而非作为 npm 依赖 +# Agent Note: 将 Cordis 以源码形式收录,而非作为 npm 依赖 Status: implemented diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index 41084092d0..cb31fe2ed1 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-16-pnpm-over-yarn.md: f5787dc3019f2c474761972afb11deb42f3950e0 -2026-06-16-pnpm-over-yarn.zh.md: 0f5acd13f6f113a569fbe4e7b92df62309c76537 +2026-06-16-pnpm-over-yarn.md: 9dee405f509897e2a173399e466d574c518fa9ab +2026-06-16-pnpm-over-yarn.zh.md: ef34e6ba5d6e22037668c9aa3507dfd0f5438ad4 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index 0f5acd13f6..ef34e6ba5d 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -1,4 +1,4 @@ -# RFC: 使用 pnpm 替代 Yarn 4 作为包管理器 +# Agent Note: 使用 pnpm 替代 Yarn 4 作为包管理器 Status: implemented @@ -8,7 +8,7 @@ Status: implemented 本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。 -切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到:(a) 解析并链接 `node_modules`,(b) 运行 workspace 脚本,(c) 强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 +切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 ## 决策 @@ -40,4 +40,4 @@ Status: implemented 在快速本地磁盘上,pnpm 的内容寻址 store 通常在冷/热安装中胜出,尤其在多个检出之间的**磁盘占用**方面优势明显(一个全局 store 通过硬链接接入每个 `node_modules`,而 Yarn 每个 worktree 复制约 279 MB——部分开发者经常为本仓库保持约 10 个或更多 worktree)。该去重优势在上述迁移时数据中**未能**体现,因为测试 store 和 `node_modules` 位于不同文件系统,硬链接失效;在单文件系统的开发机或 CI 缓存上则适用。诚实的总结:在我们的 NFS 开发文件系统上,安装速度在噪声范围内不分伯仲;迁移的理由是生态对齐、幻影依赖安全性和跨检出磁盘去重,而非原始安装时间的胜出。 -所有质量门禁(constraints、typecheck、lint、doc-sync、test:coverage 100%、build、knip、publint、echo-agent 演示冒烟测试)在 pnpm 上原样通过,这是链接器切换未引入幻影依赖破坏的正确性证明。 +所有质量门禁(constraints、类型检查、lint、doc-sync、达到 100% 的 test:coverage、构建、knip、publint 以及已构建应用的冒烟测试)均在 pnpm 下通过,证明更换 linker 没有引入幽灵依赖故障。 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 413c51c906..687332576e 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-ts-build-config.md: 9a75bcc3f043576f1cb793c38e0166aa0a010f68 -2026-06-17-ts-build-config.zh.md: c0f6bd40f3f0b06e79b9dc9f9c7812481413374c +2026-06-17-ts-build-config.md: b82e5cae6dbfaae68d3a834162085e3f7b2c0c72 +2026-06-17-ts-build-config.zh.md: e6cec899e521ee2c8325c7562329455141da8af5 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index c0f6bd40f3..e6cec899e5 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -1,4 +1,4 @@ -# RFC: TSC 优先的构建与单一 tsconfig +# Agent Note: TSC 优先的构建与单一 tsconfig Status: implemented @@ -23,6 +23,7 @@ Status: implemented - 在根目录严格配置下直接对 `vendor/*/src` 做类型检查,会触发大量不属于本项目所有权范围的类型错误。 - `packages/*/*` 对 `vendor` 的包依赖解析到 `vendor/*/lib`,以适应不同的 tsconfig 严格度。 + ## 决策 包内相对导入使用显式 `.ts` 说明符。 diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml index 0f0677a911..601a077189 100644 --- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-markdown-cross-link-lint.md: f7ab6a4fd2b2c5cadaeabd5ef0c89f1097b5b44d -2026-06-18-markdown-cross-link-lint.zh.md: 61187042a3a0d3535479ffe3a796b6c1fdb82f5e +2026-06-18-markdown-cross-link-lint.md: 2e3b0f1fcd03f244756b0030f2da758c516a2bbb +2026-06-18-markdown-cross-link-lint.zh.md: cfe973ae939eceb50d6381ecf35c80df508907c3 diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md index 61187042a3..cfe973ae93 100644 --- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md +++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md @@ -1,4 +1,4 @@ -# RFC: Markdown 交叉链接有效性检查 +# Agent Note: Markdown 交叉链接有效性检查 Status: implemented @@ -8,7 +8,7 @@ Status: implemented 本仓库的文档通过相对路径互相链接:`[topic](../implemented/2026-…-….md)`、`[the cookbook](adding-a-tool.md)`、`[architecture.md](../../architecture.md)`。此前没有任何机制验证这些目标是否存在。重命名或移动文件会静默破坏所有指向它的链接,且在读者点击之前不可见。[Doc-sync 强制](2026-06-11-doc-sync-enforcement.md)已经将两类文档漂移机械化(无法编译的代码块、陈旧的事件分类表),[verify-md-wrap](2026-06-11-doc-sync-enforcement.md) 覆盖了第三类(硬换行的段落),但死链是第四类同样可机械检查、却仍靠肉眼验证的问题。 -触发本门禁的直接案例是引入它的那次 RFC 目录重组:将 `docs/adr/` + `docs/rfc/` 统一为一个 `docs/rfc/`,下设 `proposed/`/`implemented/`/`rejected/` 子目录,手动改写了约四十条文档间链接。任何一个手误路径都会让一条死链随代码入库,而没有任何东西能拦住它。 +引入这道门禁的直接动因是 Agent Note(agent 决策记录)目录树重组:将 `docs/adr/` 与 `.agents/notes/` 统一到同一个 `.agents/notes/` 下,并设置 `proposed/`、`implemented/`、`rejected/` 子目录,需要手工重命名约 40 条文档间链接。只要有一处路径输入错误,就会在没有任何检查拦截的情况下交付断链。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented - 仅当目标是**相对路径**时才检查。跳过带协议的 URL(`https:`、`mailto:` 等)、协议相对路径(`//host`)、根绝对路径(`/path`,在检出目录中没有稳定基准)以及纯页内锚点(`#section`)。剥除 `#fragment`/`?query`,相对于链接所在文件的目录解析路径,并断言目标在磁盘上存在。 - 只报告、不改写;发现第一条死链即以非零状态退出。 -范围与其他门禁一致,另外加上 AGENTS.md 对和 `.agents/skills/` 下仓库自有的 agent skill Markdown(这些 skill 文件交叉链接到 docs 目录,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`,按真实路径去重(`CLAUDE.md` 符号链接解析到 AGENTS.md 文件)。它接入 lefthook pre-push 钩子和 CI 都会运行的 `doc-sync` 脚本,因此死链在推送前就会在本地失败——与[机械化质量门禁](2026-06-11-quality-gates.md)一致。 +检查范围与其他门禁一致,并额外包含 AGENTS.md 文件对以及 `.agents/skills/` 下仓库自有的 agent-skill(技能)Markdown(这些 skill 文件会交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`。系统按真实路径去重(`CLAUDE.md` symlink 会解析到 AGENTS.md 文件)。该检查接入 `doc-sync`,因此相关文档变更与 CI 执行同一套断链检查。 本门禁检查的是*文件存在性*,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件可解析;片段被剥除)。 @@ -28,6 +28,6 @@ Status: implemented ## 后果 -- 重命名或移动文件导致交叉链接悬空时,现在会在 pre-push 钩子和 CI 中失败,而不是等读者点击死链才发现。这使得引入本门禁的 RFC 重组具备自验证能力:改写四十条链接的同一个 PR 也添加了证明无一悬空的检查。 +- 造成交叉链接失效的重命名与移动会直接使 `doc-sync` 和 CI 失败,而不是等读者点击死链才暴露。由此,引入该门禁的 Agent Note 重组具备自校验能力:同一个 PR(Pull Request)在改写 40 条链接的同时,也添加了证明这些链接均未悬空的检查。 - `doc-sync` 链中多了一个快速 tsx 脚本;无新增依赖(mdast/GFM 技术栈已作为 `verify-md-wrap` 的 devDependencies 存在)。 -- 本门禁强制的约定——通过可机械检查的相对链接引用文档,而非裸文本或编号——记录在 [docs/AGENTS.md](../../../AGENTS.md) 中,让作者知晓门禁的存在与原因。 +- 该门禁强制执行的约定是:文档交叉引用必须使用可机械检查的相对链接,绝不能只写纯文本或编号。[docs/AGENTS.md](../../../../docs/AGENTS.md)记录了这项约定,使作者了解该门禁及其理由。 diff --git a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml index c9a4c3a6e9..cc0a877084 100644 --- a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-rfc-classification.md: 6975b74dbaa9ca9379f23b537133323cb694958a -2026-06-20-rfc-classification.zh.md: 423d5c87eb9e2cb07ed18834f4c15ffa1ac1983e +2026-06-20-agent-note-classification.md: 094233ed108e35e390cdc66b419179249c2d9173 +2026-06-20-agent-note-classification.zh.md: b424b07e051507a894c8f5961feb5fe24a9da3c6 diff --git a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md index 750a3586e0..094233ed10 100644 --- a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md +++ b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-agent-note-classification.zh.md) + ## Problem A lifecycle-only Agent Note tree — `proposed/` / `implemented/` / `rejected/` — does not record what *kind* of decision each file contains. A reader browsing one lifecycle cannot distinguish a new capability from a removal or a tooling-policy change without opening each file. diff --git a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md index 423d5c87eb..b424b07e05 100644 --- a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.zh.md @@ -1,48 +1,48 @@ -# RFC: 通过路径编码的子目录对 RFC 进行分类 +# Agent Note: 通过路径编码的子目录对 Agent Note 进行分类 Status: implemented -[English](2026-06-20-rfc-classification.md) | 中文 +[English](2026-06-20-agent-note-classification.md) | 中文 ## 问题 -`docs/rfc/` 过去仅按**生命周期**分组 RFC:`proposed/`/`implemented/`/`rejected/`。没有任何机制记录每个 RFC 属于哪一*类*决策。索引在每个生命周期下只是一个扁平列表,无法按需筛选「所有简化类」或「所有测试策略类」决策。一批简化类 RFC 在同一天落地后,这个缺口变得具体:浏览 `proposed/` 的读者无法在不逐一打开文件的情况下区分新能力、移除和工具策略变更。 +仅按生命周期组织的 Agent Note(agent 决策记录)目录树(`proposed/` / `implemented/` / `rejected/`)无法记录每个文件包含哪一*类*决策。读者浏览某个生命周期时,如果不逐一打开文件,就无法区分新功能、移除项或工具策略变更。 本仓库一贯的倾向是[机械质量门禁优于行文规范](2026-06-11-quality-gates.md):不被机器检查的约定终将腐烂。因此这里的分类方案必须可强制执行,而非靠自觉的文件头。 ## 决策 -增加第二个维度——RFC 的**类别**——并将其编码在路径中:`{lifecycle}/{class}/yyyy-mm-dd-topic.md`。文件夹本身*就是*标签。文件的位置声明其类别,封闭集合是「这些文件夹且仅限这些」,而既有的 [verify-md-links](2026-06-18-markdown-cross-link-lint.md) 门禁已经保护了移动文件所需的路径重写。 +增加第二个维度,即 Agent Note 的**类别**,并将其编码在路径中:`{lifecycle}/{class}/yyyy-mm-dd-topic.md`。文件夹*就是*标签。文件位置声明其类别;封闭集合限定为「这些文件夹且仅限这些」;既有的 [verify-md-links](2026-06-18-markdown-cross-link-lint.md) 门禁已经保护移动文件所需的路径改写。 ### 六个类别的封闭集合 | 类别 | 涵盖范围 | |---|---| -| `feature` | 面向用户或模型的新能力。 | +| `feature` | 面向用户或模型的新功能。 | | `bug-fix` | 修正缺陷或填补事后复盘暴露的空白。 | -| `simplification` | 移除代码、行为或对外表面积,不引入新能力。 | +| `simplification` | 移除代码、行为或对外表面积,不引入新功能。 | | `architecture` | 关于**交付源码**的结构性决策——包(package)之间的关系、运行时词汇。 | | `process` | **围绕**代码的工具、策略或工作流,而非运行时行为。 | | `testing` | 测试基础设施与策略。 | -`architecture` 与 `process` 的分界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。本 RFC 本身是一个 `process` 决策——它改变的是仓库的组织方式和门禁,而非 harness 的运行时行为——因此它位于 `implemented/process/` 下。 +`architecture` 与 `process` 的分界是:**architecture** 关乎我们交付的源码;**process** 关乎源码周边的工具与工作流。本 Agent Note 本身属于 `process` 决策:它改变仓库的组织方式与门禁,而不是 harness 的运行时行为,因此位于 `implemented/process/` 下。 ### 两道门禁 两者都是 `doc-sync`(文档同步门禁)的成员,风格与 `verify-md-wrap` 一致(tsx ESM,只校验不生成,首个违规即以非零退出码退出): -- **`scripts/verify-rfc-classification.ts`**——封闭集合与索引新鲜度。它断言生命周期文件夹下的每个文件都位于规范集合中的某个类别文件夹内(生命周期根目录下的散落 `.md` 或未知类别文件夹均判定失败),并断言生成的 [INDEX.md](../../INDEX.md) 与从目录树重新渲染的结果逐字节一致(见[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md))。规范类别集合以 `const` 形式定义在 `scripts/rfc-index.ts` 中——这是与生成器共享的机器真源——而 [README](../../README.md) 以行文形式记录它;类别*描述*保持手写,索引由机器生成。 -- **`scripts/verify-doc-refs.ts`**——源码注释中的文档引用。RFC 路径不仅被 Markdown 引用,也被 TypeScript 文档注释引用(以仓库根为起点的路径,如 `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`)。`verify-md-links` 从未扫描过这些引用,因此重组可能使它们静默失效。此门禁扫描 `packages/**` 和 `examples/**` 下仓库自有的 `.ts` 文件(排除构建产物 `lib/` 和 `vendor/`),查找 `docs/….md` 形式的 token,将每个以仓库根为起点的路径解析并断言其存在。它要求 `.md` 扩展名,因此无扩展名的行文引用(`docs/postmortem/0001`、`docs/architecture.md § Extending The Harness`)不受影响。 +- **`scripts/verify-agent-note-classification.ts`**:定义封闭的生命周期与类别集合。它断言生命周期文件夹下的每个文件都位于规范集合中的类别文件夹内(生命周期根目录下散落的 `.md` 或未知类别文件夹都会失败),并拒绝集中式 `INDEX.md`。规范集合位于 `scripts/agent-note-tree.ts` 中,[README](../../README.md)则以行文记录每个类别。 +- **`scripts/verify-doc-refs.ts`**:检查引用文档的源码注释。Agent Note 路径不仅出现在 Markdown 中,也出现在 TypeScript 文档注释中(例如以仓库根为起点的 `.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md`)。`verify-md-links` 看不到这些引用,因此目录重组可能静默留下失效引用。该门禁扫描 `packages/**` 与 `examples/**` 下仓库自有的 `.ts` 文件(排除已构建的 `lib/` 与 `vendor/`),查找 `docs/….md` 和 `.agents/notes/….md` token,解析每个以仓库根为起点的路径并断言其存在。它要求使用 `.md` 扩展名,因此不处理无扩展名的行文。 ## 曾考虑的替代方案 - **在每个文件中添加 `Classification:` 行文行**(紧邻 `Status:`),由门禁解析。可行,但它将路径已能承载的事实重复到文件中,且行内容可能与所在文件夹不一致。路径编码使标签与其存储合二为一,没有需要保持同步的东西。 - **设立 `refactor` 类别。** 与 `simplification` 几乎完全重叠;唯一有人试图用来区分的标准是「可观察行为是否改变?」,而 `simplification` 已经编码了这一点(它不改变)。一个类别即可,无需两个。 -- **从文件系统自动生成索引。** 此处最初否决,以保持索引手写;后被[生成 RFC 索引表](2026-07-04-generate-rfc-index-tables.md)取代——当堆叠的提案潮使手写表格成为仓库中冲突最频繁的文档区域后,列表改为完全生成的 [INDEX.md](../../INDEX.md),而 README 行文保持人工维护。 +- **生成或手工维护的语料索引。** 不予采纳:生命周期/类别目录树才是权威结构;集中式清单会制造合并热点,却没有提供目录树导航或仓库搜索无法实现的发现能力。单独的[索引提案](../../rejected/process/2026-07-04-generate-agent-note-index-tables.md)记录了被放弃的生成形状。 ## 后果 -- 每个 RFC 现在都位于一个类别文件夹下,索引在每个生命周期内按类别分组。读者只需扫一个标题即可看到所有简化类或所有测试类决策。 +- 每份 Agent Note 都位于一个类别文件夹下。读者浏览单个文件夹,即可查看某个生命周期内的全部简化或测试决策。 - `doc-sync` 链中多了两个快速 tsx 脚本;无新依赖(mdast/GFM 栈已因 `verify-md-wrap`/`verify-md-links` 而存在)。 -- 新增类别是一个刻意的动作:修改 `scripts/rfc-index.ts` 中的 `const` 和 [Classification 章节](../../README.md#classification),而非仅仅 `mkdir` 一个文件夹。门禁拒绝未知文件夹,因此临时类别无法悄悄混入。 -- 源码注释中的文档引用现在也受门禁保护——一个被移动或重命名的文档如果被 `.ts` 注释引用,pre-push 钩子就会失败,堵住了 `verify-md-links` 在结构上无法看到的一类漂移。 +- 新增类别必须是显式决策:修改 `scripts/agent-note-tree.ts` 中的 `const` 与 [Classification 章节](../../README.md#classification),而不是只用 `mkdir` 创建文件夹。门禁会拒绝未知文件夹,因此临时类别无法悄然混入。 +- 源码注释中的文档引用同样受门禁约束:被 `.ts` 注释引用的文档一旦移动或重命名,`doc-sync` 与 CI 中的 `verify-doc-refs` 就会失败,从而堵住 `verify-md-links` 在结构上无法发现的一类漂移。 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 7b498b0a81..ca85bc0c2f 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-core-data-structures-catalog.md: 933844d0f442306af238bfc459372b1c97414f87 -2026-06-20-core-data-structures-catalog.zh.md: 620457b28d6b4aa36ec139da7bcd892ed78e2673 +2026-06-20-core-data-structures-catalog.md: a2f5e0e7b06e34cb361e4944bbfa3692355a2cbe +2026-06-20-core-data-structures-catalog.zh.md: ee740fd1fb7e5c335bf80e57e27d0a4651be49bd diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 620457b28d..ee740fd1fb 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC: 核心数据结构目录与 `ts type-equiv` 漂移门禁 +# Agent Note: 核心数据结构目录与 `ts type-equiv` 漂移门禁 Status: implemented @@ -6,13 +6,13 @@ Status: implemented ## 问题 -一位想要理解 harness 的读者,可以在 [architecture.md](../../../architecture.md) 中找到它的*行为*(服务映射、session/turn/step 生命周期、事件分类体系),但没有一个集中的地方描述它的*词汇*——即行为所操作的数据结构。类型形状只存在于源码中,分散在各个 `packages/*/src/types.ts` 里,因此要理解「什么是 `Message`、`SessionEvent`、`StreamChunk`」就得直接阅读声明。一份行文目录会有帮助,但如果目录是对类型定义的转述或粘贴复制,那么字段一改它就会腐烂——而失去同步的类型文档比没有更糟,因为读者会信任它。 +试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、session/turn/step 生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解“什么是 `Message`、`SessionEvent`、`StreamChunk`”,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 -因此这项工作包含两个交织的问题:**这样的目录应当收录什么**(范围界定问题——一个 harness 有数十个跨包类型,全部堆上去对谁都没帮助),以及**如何防止粘贴的类型定义漂移**(持久性问题)。本 RFC 记录两项决策。它的姊妹篇 [生成式 Cordis 事件 + 服务目录](2026-06-20-generated-cordis-catalog.md) 是*接线*轴的补充:本篇编目数据结构,那篇编目传递数据结构的事件与服务。 +因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十个跨包类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note(agent 决策记录)记下了这两项决策。与它配套的[生成式 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 ## 决策 -新建 `docs/core-data-structures/` 文件夹编目词汇,并新增 `verify-type-equiv` doc-sync(文档同步门禁)门禁,确保每处粘贴的类型定义与源码逐字节一致。 +新增的 `docs/core-data-structures/` 目录对这些词汇编目,并配有新的 `verify-type-equiv` 文档同步门禁,使每个粘贴的类型声明及其 JSDoc 与源码保持同步。 ### 何为"核心"——主干与 seam 的分界线 @@ -29,12 +29,12 @@ Status: implemented ### `ts type-equiv` 机制——既逐字又防漂移 -持久性需求很具体:文档应当展示**当前类型定义的原文**(让读者看到真实形状,而非转述),**并且**机械地保证与源码一致。仓库已经能编译围栏 ` ```ts ` 块(`doc-typecheck`),但一个真正可编译的块需要 import 噪音,且只证明*可赋值性*而非*字节相等*——一个类型相同但改了名的字段会通过。因此: +持久性要求很具体:文档展示当前类型声明与原始 JSDoc 的**逐字**内容(让读者看到真实形状和源码契约,而非复述),**并且**以机械方式保证其与源码匹配。仓库已经会编译 ` ```ts ` 围栏块(`doc-typecheck`),但真正接受类型检查的块需要导入噪音,而且只能证明*可赋值性*——字段改名或 JSDoc 变化仍可能通过。因此: -- 类型定义逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。`doc-typecheck` 识别该围栏并跳过它(裸定义不能独立编译),且**将其排除在 opt-out 比例之外**——它是一个独立检查的类别,而非未检查的草稿。 -- 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其与声明的符号**逐字节匹配源码**——之所以选择这种方式而非编译式 `_Check` 可赋值性断言,正是因为我们需要的属性是字节相等,而非可赋值性。 +- 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载与源码等价的类环境投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在退出检查比例之外**——它们是单独受检的类别,而不是未经检查的草图。 +- 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其声明结构和每条 JSDoc 注释都与所声明的符号匹配,只忽略格式空白和非 JSDoc 注释。普通块保留完整声明。`public-api` 投影保留类的公共字段、构造函数、访问器和方法及其原始 JSDoc,同时移除实现体以及私有或受保护成员。之所以选择它而非编译式 `_Check` 断言,是因为目录所保留的是源码名称与文档一致性,而不是可赋值性。 - 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。 -- 接入 `doc-sync`,因此与其他文档门禁在同一条 lefthook pre-push 和 CI 路径中运行。 +- 接入 `doc-sync`,因此相关文档变更会在本地运行它,CI 也会与其他文档检查一起运行它。 ### 维护是作者的职责,门禁作为兜底 @@ -43,18 +43,18 @@ Status: implemented ## 曾考虑的替代方案 - **平铺罗列所有跨包词汇**:`BashExecRequest` 测试案例否决了它。如果 seam 词汇算"核心",目录对谁都没帮助;分层的主干与 seam 结构胜出。 -- **编译式 `_Check` 可赋值性断言**替代逐字节源码匹配:否决,因为我们需要的属性是字节相等而非可赋值性——一个类型相同但改了名的字段会通过可赋值性检查。 +- **用编译式 `_Check` 可赋值性断言**代替源码匹配:否决。可赋值性不会保留名称或 JSDoc;同类型字段改名或契约注释变化仍会通过。 - **来源信息作为行文中的指令注释**:否决,改用集中 manifest;其强制的 1:1 对应确保一个块永远不会被静默漏检,一条条目也永远不会腐烂。 ## 验证教训 主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及 session/persistence 拆分的逐一测试。 -`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅仅是 manifest 中列出的文档。否则一个未登记的 `type-equiv` 块会逃脱所声称的一对一检查。因此门禁将此类块报告为遗留块。本 RFC 将这条快速失败的扫描规则与主干-seam 分界线和逐字节匹配决策一并记录;生成式 Cordis 目录在[其 RFC](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 +`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是清单点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为孤儿。本 Agent Note 将这条失败关闭扫描规则与主干/接缝、逐字匹配决策一并记录;生成式 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 ## 后果 -- 词汇现在有了一个**不会静默漂移**的唯一归属:源码中的字段重命名会在 pre-push 钩子和 CI 中导致 `verify-type-equiv` 失败,直到粘贴内容被刷新。 +- 这些词汇现在有一个**无法悄然漂移**的唯一归属:源码中的字段或公共类成员发生变化后,`doc-sync` 和 CI 中的 `verify-type-equiv` 会持续失败,直至粘贴内容刷新。Cordis 服务方法仍由生成式服务目录负责,而不会在此重复。 - 主干与 seam 分界线是一个可复用的范围界定工具,而非一次性的:同一条「你编写/持有/接收的东西是核心;为其提供类型推导/渲染/持久化的机制是细节」规则,后来也被用于界定事件/服务目录的 harness 层与继承层分层。 - `ts type-equiv` 围栏是继 ` ```ts `(编译)和 ` ```ts ignore-check `(草稿)之后的第三种文档块类别。后续的姊妹门禁又增加了第四种 ` ```ts cordis-catalog `(生成签名),复用了相同的跳过并排除处理。 - 添加或重塑核心类型现在附带一项文档义务,作者必须履行(门禁无法检测缺失的*新*类型),由 `dsh-code-review` 检查清单兜底。 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml index 13f31c3ebe..3182edd49f 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-generated-cordis-catalog.md: 8b979764c1ae6693b77817fc84381e40e56f8d36 -2026-06-20-generated-cordis-catalog.zh.md: 608ab3e0429fab43abec634ce0955f46306d8529 +2026-06-20-generated-cordis-catalog.md: b5957cf06a9316447aae70183de462024bb24be3 +2026-06-20-generated-cordis-catalog.zh.md: 33983d7693e1c67ac210b3ce23d28e7d46571ff9 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md index 608ab3e042..33983d7693 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC: 生成式 Cordis 事件与服务目录 +# Agent Note: 生成式 Cordis 事件与服务目录 Status: implemented @@ -8,13 +8,13 @@ Status: implemented 插件作者需要两个参考面,而此前没有任何单一文档能提供:他们可以监听的每一个 Cordis **事件**(含精确签名与分发模式),以及他们可以调用的每一个 `ctx.<key>` **服务**(含精确接口)。相关信息虽然存在,但散落各处:`docs/architecture.md` 中一张手工维护的事件分类*表格*(名称 + 行文描述的 Mode/Purpose,由 `verify-event-taxonomy` 做名称集合校验)、一张服务映射表(8 行角色描述),以及 `interface Events` / `interface Context` 声明本身。分类表格还有一个盲区:它无法捕获全新的*未记录*事件——名称集合校验器只检查两侧已有的名称。 -这是 [core-data-structures 目录](../../../core-data-structures/core.md)([其 RFC](2026-06-20-core-data-structures-catalog.md))在连线轴上的补充:后者编目的是 agent loop(智能体循环)流转的*数据结构*(经校验的手工粘贴);本 RFC 编目的是移动这些数据结构的*事件与服务*。 +这是对[核心数据结构目录](../../../../docs/core-data-structures/core.md)([其 Agent Note(agent 决策记录)](2026-06-20-core-data-structures-catalog.md))在接线维度上的补充:前者对循环传递的*数据结构*编目(经验证的手工粘贴),本文则对传递它们的*事件和服务*编目。 ## 决策 从源码生成目录,取代手工维护表格并校验子集的方式。 -`scripts/gen-cordis-catalog.ts` 使用 TypeScript 编译器 API,从声明和源码 JSDoc 分别输出事件参考与服务参考。事件包含分发模式;服务包含公开签名。确定性的 `--write` 和 `--check` 模式使两个页面成为生成产物,新鲜度由 `doc-sync`(文档同步门禁)强制保障。 +`scripts/gen-cordis-catalog.ts` 使用 TypeScript 编译器 API,根据声明和源码 JSDoc 分别生成事件与服务参考。事件包含分派模式及其原始成员 JSDoc;服务包含公共签名及各方法的原始 JSDoc。确定性的 `--write` 和 `--check` 模式使两个页面成为生成产物,并由 `doc-sync` 强制检查新鲜度。 纯生成在此处是正确的,因为代码库足够规范,AST 就是全部事实:每个事件/服务名称都是字符串字面量,可以往返映射到静态声明——不存在动态命名的事件,也不存在仅运行时的服务。因此生成的文档不可能出错,且从结构上消除了未记录事件的缺口(生成器枚举源码,而非校验手写子集)。 @@ -22,8 +22,8 @@ Status: implemented - **`@mode` 标签,交叉校验。** 每个 harness 事件的 JSDoc 携带一个显式的 `@mode emit|waterfall|parallel|serial` 标签;缺少标签时生成器直接报错。当签名形状具有决定性时——尾部参数为 `next: () => …` 在结构上即为 waterfall(瀑布式事件)——生成器断言标签与之一致,矛盾时直接报错。emit/parallel/serial 的区别在结构上不可见(`session/flush` 返回 `Promise<void> | void` 且无 `next`,有序的 `agent/pre-step` 检查点亦然),因此信任标签。编写规则见 [AGENTS.md](../../../../AGENTS.md)。 - **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/hmr/timer)是插件同样可见的固定 vendor 源码;它从生成器中一张人工维护的表格简洁渲染(名称 + 一行描述 + 源码指针),而非遍历 vendor AST。原因是 cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 接口面仅在有意的 vendor 同步时才变化。 -- **交叉链接到数据结构目录。** 签名中的类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition` 等)链接到记录该类型的 core-data-structures 页面。映射是生成器中一个小型的人工维护常量,而非 `type-equiv.manifest.json`——后者记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 -- **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,`doc-typecheck` 识别后跳过(裸签名片段不能独立编译),并排除在 opt-out 比例之外——与 `type-equiv` 块获得相同待遇。 +- **指向数据结构目录的交叉链接。** 签名中由仓库拥有的每个类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition`……)都会通过人工维护的映射链接到其主要核心数据结构页面。AST 遍历采用失败关闭策略:每个参数、泛型约束/默认值和返回类型引用都必须已映射、是签名自身的类型参数、是点名的 TypeScript/Cordis 基础类型,或带有点名的例外及其非目录文档归属。违规会连同源码位置汇总报告,并点明相应的归属列表。该映射不会复用 `type-equiv.manifest.json`,因为后者记录 `…Map` 符号,而签名引用派生的联合类型名,并且会在多个页面列出某些符号。 +- **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,并把原始事件或公共方法 JSDoc 直接放在其声明之前。`doc-typecheck` 会识别并跳过这些裸片段,将其排除在退出检查比例之外——与 `type-equiv` 块的处理相同。 本决策**取代** [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)中事件分类的那一半:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射的角色表格作为人工行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 和 verify-type-equiv 不受影响。 @@ -31,11 +31,11 @@ Status: implemented - **校验而非生成(退役的分类检查所做的事)**:*仅对本参考面*反转了这一策略。此处的数据可以机械地完整获取,因此生成严格强于对手工表格做名称集合校验(完整签名、不会漂移、能捕获未记录事件)。 - **遍历 vendor AST 以获取继承层**:否决,改用人工维护表格。cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段,且固定的 vendor 接口面仅在有意同步时才变化。 -- **复用 `type-equiv.manifest.json` 作为签名交叉链接映射**:否决,改用小型人工维护常量。manifest 记录的是 `…Map` 符号,而签名引用的是派生联合类型名,且有少数符号出现在两个页面上。 +- **复用 `type-equiv.manifest.json` 作为签名交叉链接映射**:否决,改用完整的人工维护常量和失败关闭覆盖。清单记录 `…Map` 符号,而签名引用派生的联合类型名,并且会在多个页面列出某些符号。显式映射让每个渲染目标和每个非目录例外都成为可评审的决策。 ## 后果 -- 目录不会漂移:源码变更而已提交文件未反映时,`verify-cordis-catalog` 在 pre-push 钩子和 CI 中失败。新事件缺少 `@mode` 标签,或标签与签名矛盾,生成器直接报错。 -- 事件的行文描述现在有了唯一归属地——声明处的 JSDoc。JSDoc 写得单薄,目录条目就单薄,这迫使作者在源码处做好文档(生成器是 AGENTS.md「每个导出都有语义 JSDoc」规则的强制函数)。 +- 目录不会发生漂移:提交文件未反映的源码变化会使 `doc-sync` 和 CI 中的 `verify-cordis-catalog` 失败。新事件缺少 `@mode` 标签、标签与其签名冲突,或签名类型未分类,都会直接使生成器失败。 +- 事件与服务方法契约只有一个归属——声明处的 JSDoc。目录会在生成的签名块中重复该原始 JSDoc,并使用其描述部分作为条目正文,因此单薄的源码文档只会生成单薄的目录条目。 - 继承层是手工摘要,因此 vendor 同步若新增或重命名了 cordis-core 事件或 `ctx` 成员,需要同步编辑 `gen-cordis-catalog.ts` 中的人工维护表格。这是不遍历固定 vendor 源码的有意代价;它很少变化,且在生成器中有明确标注。 - `verify-event-taxonomy.ts` 被删除,`docs/architecture.md` 的事件表格也已移除;之前链接到特定表格行的人现在会落在生成目录上。 diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml index 80865d0269..b64d63b3ff 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-tool-schema-catalog.md: fe7ec47ac89c157482f612a374ca8773d7d0867f -2026-07-02-tool-schema-catalog.zh.md: 1567ff5dd5c53da501a86412d0e29127d33d29f6 +2026-07-02-tool-schema-catalog.md: c8cc69df428f6eee0f66ed976865afe2a0702448 +2026-07-02-tool-schema-catalog.zh.md: 7caa9e8747d8fd09806342035d185e664f7be7b7 diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md index 1567ff5dd5..7caa9e8747 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC: 生成式工具 schema 目录(启动并采集) +# Agent Note: 生成式工具 schema 目录(启动并采集) Status: implemented @@ -10,7 +10,7 @@ Status: implemented ## 决策 -通过**启动每个工具插件并读取其注册的 schema** 来生成目录,而非解析源码。`scripts/gen-tool-catalog.ts` 将每个已发布的工具包(package)挂载到一个新的 Cordis `Context`(带 `SystemPrompt` + `ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`(即发送给模型的 `ToolSchema[]`),dispose(资源释放)该 context,然后为每个包渲染一个 `## <package>` 小节,每个工具一个 ` ```json ` 的 `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI(命令行界面)形态一致:默认 `--write` 重新生成,`--check` 在已提交副本陈旧时失败,输出是确定性的(按 manifest(元数据清单)排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync`(文档同步门禁)内运行,因此新鲜度门禁在 lefthook pre-push 和 CI 路径中与其他文档门禁一同触发。 +目录通过**启动每个工具插件并读取其已注册 schema** 来生成,而不是解析源码。`scripts/gen-tool-catalog.ts` 在全新的 Cordis `Context` 上挂载每个已发布工具包(带有 `SystemPrompt`、`ToolRegistry` 以及插件 `apply` 所读取的注入接缝),调用 `ctx.tools.schemas()`——也就是发送给模型的确切 `ToolSchema[]`——随后释放上下文,并为每个包渲染一个 `## <package>` 章节,每个工具附带一个 ` ```json ` `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形状一致:默认 `--write` 重新生成;提交副本陈旧时 `--check` 失败;输出具有确定性(按清单排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一项新鲜度检查。 ### 为何启动而非解析(核心要点) @@ -21,7 +21,7 @@ Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是 - `tool-subagent` 的工具名是 `config.toolName ?? 'subagent'`——加载时选定,并非字面量。 - MCP 插件可以通过 `ctx.tools.register()` 直接注册**原始 JSON Schema**,完全不经过 `defineTool`,因此结构化枚举 `defineTool(` 调用点会遗漏。 -唯一忠实的真源是插件加载后注册表实际持有的 schema。启动是[测试策略](../../../testing.md)中「验证世界,而非自我报告」这一原则在文档生成器上的应用:读取已发布的产物,而非对它的再推导。 +唯一忠实的事实来源,是插件加载后注册表实际持有的 schema。启动插件是把[测试策略](../../../../docs/testing.md)中“验证现实,而非自我报告”的准则应用到文档生成器:读取已发布产物,而非重新推导一份。 ### 恢复「不会静默遗漏」的保证 @@ -33,7 +33,7 @@ Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是 ### 范围 -`packages/*/tool-*` 下已发布的产品工具包,每个以默认配置启动:`dsh-tool-bash`(`bash`、`bash_output`、`bash_kill`)、`dsh-tool-todo`(`todo_write`)、`dsh-tool-subagent`(`subagent`)。`examples/` 下的演示工具(`echo`)被排除,与 Cordis 目录仅覆盖 packages 的范围一致——演示工具不属于读者所查阅的产品接口。 +`packages/*/tool-*` 下已发布的产品工具包,每个都使用默认配置启动,包括 `dsh-tool-bash`(`bash`)、`dsh-tool-tasks`(`task_output`、`task_list`、`task_kill`)和 `dsh-tool-subagent`(`subagent`)。仅供示例使用的工具不在范围内。 目录的单位是包,而非每个配置化的工具实例。每个包以默认配置启动一次;加载时的别名(如 `subagent_fork`)会注明,但不枚举所有部署排列。部署清单是一个独立的、无界的接口。 @@ -49,7 +49,7 @@ schema 块使用 ` ```json `,而非自定义的 `ts` 系围栏。`doc-typechec ## 后果 -- 目录不会漂移:工具 schema 变更而已提交文件未反映,`verify-tool-catalog` 会在 pre-push 钩子和 CI 中失败。新 `tool-*` 包未加入 manifest 则完整性守卫直接报错。 +- 目录不会发生漂移:提交文件未反映的工具 schema 变化会使 `doc-sync` 和 CI 中的 `verify-tool-catalog` 失败。新增的 `tool-*` 包若未加入清单,会直接使完整性守卫失败。 - 工具描述文本有唯一归属——源码中 `defineTool` 的 `description`——生成的条目质量取决于它,与 Cordis 目录对事件 JSDoc 施加的强制力相同。 - 生成器导入并执行工作区包(这是仓库中第一个这样做的脚本;其他脚本只读文本)。它通过根 `tsconfig` 的 `paths` 映射在 `tsx` 下运行,使用与演示和测试相同的未构建源码路径,因此不需要构建步骤。 - 未来某个工具背后新增一个能力 seam,意味着 manifest 中需要新增一条配方条目(声明要挂载哪些 seam)。这正是上文指出的有意为之的手写成本;仅在新增工具包时才需变更。 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml index d5ad8c6180..bd5dda5aa4 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-03-documentation-graph-atlas.md: 3cb8e685452a5b013798d0c3f661fca218b3d69d -2026-07-03-documentation-graph-atlas.zh.md: f03268c63c6d834c59af6cf831a34e5a5eaf40d7 +2026-07-03-documentation-graph-atlas.md: 9a30b13f9db6ceb2715517230e349cbe083850ec +2026-07-03-documentation-graph-atlas.zh.md: 438f1e6de696712d8dd2f1b503d15d1b42506cf9 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md index f03268c63c..438f1e6de6 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -1,4 +1,4 @@ -# RFC: 面向维护者与 SDK 用户的文档关系图索引 +# Agent Note: 面向维护者与 SDK 用户的文档关系图索引 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -仓库已有若干高可信度的文档面,各自覆盖不同维度:[module-graph.md](../../../module-graph.md) 由包(package)的 `peerDependencies` 生成;生成的 [Cordis events](../../../cordis-catalog/events.md) 与 [services](../../../cordis-catalog/services.md) 目录由 Cordis 的 `Events` 和 `Context` 声明生成;[tool-catalog.md](../../../tool-catalog.md) 通过启动已发布的 tool 插件生成;[core-data-structures/](../../../core-data-structures/core.md) 使用 `ts type-equiv` 块保持粘贴的类型定义与源码同步。 +仓库已经有若干高可信文档表面,各自覆盖不同维度:[module-graph.md](../../../../docs/module-graph.md) 根据包的 `peerDependencies` 生成;生成式 [Cordis 事件](../../../../docs/cordis-catalog/events.md)和[服务](../../../../docs/cordis-catalog/services.md)目录根据 Cordis `Events` 和 `Context` 声明生成;[tool-catalog.md](../../../../docs/tool-catalog.md) 通过启动已发布工具插件生成;[core-data-structures/](../../../../docs/core-data-structures/core.md) 则使用 `ts type-equiv` 块使粘贴的类型定义与源码保持同步。 这些参考文档是准确的,但大多是目录式的。维护者仍需自行综合关系:哪些包构成一个能力 seam、哪个应用组装了具体的主干、哪些事件是持久的而哪些是实时的、钩子或策略插件在哪里可以拦截工作、以及哪个面向模型的工具依赖哪个服务。SDK 用户从另一个角度面临同样的问题:「我想要某种行为,应该安装或加载哪个包?应该扩展哪个事件/服务/工具?」 @@ -14,7 +14,7 @@ Status: implemented ## 决策 -新增生成的关系图文档,索引位于 [docs/graph-atlas.md](../../../graph-atlas.md),由专用生成器产出,并通过 `pnpm run verify-doc-graphs` 及既有的目录新鲜度检查(作为 `doc-sync` 的一环)进行验证。 +新增生成式关系图文档,由聚焦的生成器产出并在 [docs/graph-atlas.md](../../../../docs/graph-atlas.md) 建立索引;作为 `doc-sync` 的一部分,通过 `pnpm run verify-doc-graphs` / 现有目录新鲜度检查进行验证。 该索引是既有目录之上的关系层。它不取代精确的参考文档,而是链接到它们并解释各部分如何组合在一起。 @@ -28,20 +28,21 @@ Status: implemented ### 首批发布的索引 -首批索引链接十个关系面。包拓扑与工具-包能力映射位于已有的生成目录中(这些目录已拥有相应事实);其余聚焦图表由 `scripts/gen-doc-graphs.ts` 生成。 +该索引链接十一种关系表面。包拓扑和工具包所提供的功能位于已经拥有这些事实的现有生成式目录中;其余聚焦图表由 `scripts/gen-doc-graphs.ts` 生成。 | 关系图 | 维护模式 | 真源 | |---|---|---| -| [模块依赖图](../../../module-graph.md) | generated | `packages/*/*/package.json` 的 peer dependencies 加包分组路径 | -| [工具 schema 目录与包映射](../../../tool-catalog.md) | generated | 启动收集的工具 schema 加工具-包的服务/副作用元数据 | -| [能力 seam 与核心服务](../../../capability-seams.md) | hybrid generated | Cordis 服务声明加 `gen-doc-graphs.ts` 中的角色 manifest | -| [echo-agent 应用组合](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | -| [coding-agent 应用组合](../../../../examples/coding-agent/composition.md) | hybrid generated | `examples/coding-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | +| [模块依赖图](../../../../docs/module-graph.md) | 生成式 | `packages/*/*/package.json` 的 peer dependency 与包分组路径 | +| [工具 schema 目录与包映射](../../../../docs/tool-catalog.md) | 生成式 | 启动后采集的工具 schema,以及工具包服务/效应元数据 | +| [能力接缝与核心服务](../../../../docs/capability-seams.md) | 混合生成式 | Cordis 服务声明,以及 `gen-doc-graphs.ts` 中的角色清单 | +| [tui-agent 应用组合](../../../../examples/tui-agent/composition.md) | 混合生成式 | `examples/tui-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | +| [headless-agent 应用组合](../../../../examples/headless-agent/composition.md) | 混合生成式 | `examples/headless-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | +| [cordis-agent 应用组合](../../../../examples/cordis-agent/composition.md) | 混合生成式 | `examples/cordis-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | -| [事件生产者/消费者矩阵](../../../event-producer-consumer.md) | hybrid generated | Cordis 事件声明、AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 调用点,以及显式的动态分发覆盖 | -| [agent 轮次与步骤生命周期](../../../agent-lifecycle.md) | curated | architecture.md 的循环生命周期、Cordis 目录链接与会话事件语义 | -| [工具执行流水线](../../../tool-execution-pipeline.md) | curated | 工具流水线语义与 `tools/execute` waterfall(瀑布式事件) | -| [ACP 快照回放](../../../../packages/ui/acp/snapshot-replay.md) | curated | 快照 harness 行为 | +| [事件生产者/消费者矩阵](../../../../docs/event-producer-consumer.md) | 混合生成式 | Cordis 事件声明、经 AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 位置,以及显式动态分派覆盖 | +| [agent turn 与 step 生命周期](../../../../docs/agent-lifecycle.md) | 人工维护 | architecture.md 循环生命周期、Cordis 目录链接,以及 session 事件语义 | +| [工具执行管线](../../../../docs/tool-execution-pipeline.md) | 人工维护 | 工具管线语义与 `tools/execute` waterfall(瀑布式事件)| +| [ACP(Agent Client Protocol)快照回放](../../../../packages/ui/acp/snapshot-replay.md) | curated | 快照 harness 行为 | ### 为什么由生成器拥有文档 diff --git a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml index 520426ca4b..21465703e1 100644 --- a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-cordis-jsdoc-completeness-gate.md: 8eaa997eb734b32e7e4a45d96def15dc541af1d5 -2026-07-04-cordis-jsdoc-completeness-gate.zh.md: a49189b3162fbd0eba9e043c683f0fc56d0bf7d6 +2026-07-04-cordis-jsdoc-completeness-gate.md: c7c39986414437ce4d0d4c64f9e25f47485fdf5c +2026-07-04-cordis-jsdoc-completeness-gate.zh.md: f1e7ec824ebae119c1cb27ca4dd9f0d8330dde1a diff --git a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md b/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md index a49189b316..f1e7ec824e 100644 --- a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md @@ -1,4 +1,4 @@ -# RFC: 针对 Cordis 对外服务接口的 JSDoc 完整性门禁 +# Agent Note: 针对 Cordis 对外服务接口的 JSDoc 完整性门禁 Status: implemented @@ -12,7 +12,7 @@ AGENTS.md 中的规则(「每个导出都有解释语义的 JSDoc」)只能 ## 决策 -扩展 `scripts/gen-cordis-catalog.ts`(同一次遍历、同一个 `@mode` 先例),对其编目的所有内容强制 JSDoc 完整性。`verify-cordis-catalog` 在 `doc-sync`(文档同步门禁)内运行,CI 和 lefthook pre-push 钩子都已执行该命令,因此门禁无需新增任何接线(质量门禁原则:单一真源)。 +扩展 `scripts/gen-cordis-catalog.ts`——复用同一次遍历和同一套 `@mode` 先例——对它编目的所有内容强制执行 JSDoc 完整性要求。`verify-cordis-catalog` 在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一门禁,无需另行接线。 契约如下: @@ -22,20 +22,20 @@ AGENTS.md 中的规则(「每个导出都有解释语义的 JSDoc」)只能 - **遍历可检查的显式性**:门禁是纯 AST 遍历(不使用类型检查器),因此服务方法必须显式标注返回类型(推断的返回类型无法分类),接口参数必须是简单标识符(解构模式没有名称供 `@param` 匹配)。 - **违规聚合**为一条错误信息,列出所有违规项——修复时一次看到完整清单。此前快速失败的 `@mode` 检查也移入同一份聚合报告,消息文本不变。 -这些标签**仅用于强制检查**:`parseJsDoc` 现在在遇到第一个块标签时截止描述性文字(标准 JSDoc 语义,同时也防止多行标签描述泄漏到目录中充当正文),因此 `@param`/`@returns` 不会改变渲染出的目录。 +生成器保留同一源码注释的两种视图:`parseJsDoc` 在第一个块标签处结束条目正文,而 `ts cordis-catalog` 签名块包含原始 JSDoc,并完整保留 `@param`、`@returns` 和 `@mode`。因此,读者可以看到完整的源码契约,而块标签文本不会泄漏到周围正文中。 `packages/core/agent/tests/gen-cordis-catalog.spec.ts` 中的负路径测试对合成 fixture(测试前置数据)运行 `collectEvents`/`collectServices`,验证每条守卫都会触发且免检规则成立。撰写规则写在根 [AGENTS.md](../../../../AGENTS.md) 的约定条目中,与 `@mode` 规则并列。 ## 曾考虑的替代方案 - **ESLint 规则**:无法看到该范围的机器定义(哪些 `interface Events` 成员、哪些 `ctx.<key>` 类构成 Cordis 对外服务接口);目录生成器在每次运行时恰好计算这层映射,因此门禁放在那里。 -- **将标签渲染到目录中**:曾考虑将服务部分重构为逐方法条目,但有意推迟:方法文档的消费场景是源码 JSDoc 加 IDE 悬停,目录保持索引定位。 +- **将每个方法展开为单独的正文小节**:否决。目录保留一个服务章节和一个签名块,以维持可扫读性;附着于每个声明的 JSDoc 则在原处保留完整的方法契约。 - **逃逸标签**:不设。该接口面小且经过策展(采纳时 12 个服务、57 个方法、27 个事件),要点在于检查不可豁免。 ## 后果 -- 新增事件或服务方法时,若参数或返回值未写文档则无法落地:生成器拒绝重新生成,`verify-cordis-catalog` 在 pre-push 和 CI 中失败。采纳时发现的约 139 处缺口在同一个变更中补齐,门禁以绿色状态落地。 +- 新事件或服务方法不能带着未记录的参数或结果落地:生成器会拒绝重新生成,`verify-cordis-catalog` 也会使 `doc-sync` 和 CI 失败。采纳时发现的约 139 处缺口已在同一变更中补齐,因此门禁以绿色状态落地。 - 服务接口必须显式标注返回类型并使用标识符参数。两项约束在采纳时均未构成限制(所有方法已有标注;不存在解构的 seam 参数);但二者现在是承重要求,违反时会被机械检测到。 - AGENTS.md 中通用的 JSDoc 规则(「一行能说清就用一行」)在此接口上获得了更严格的特例:仅当方法无参数且返回 void 时,一行摘要才足够。 - 为 `next` 或 `this` 写 `@param` 合法但不检查——这是有意的不对称:门禁强制载荷契约,拒绝要求样板代码。 -- 渲染出的目录不受这些标签影响(正文在第一个块标签处截止)。如果后续需要方法级渲染,那是一个独立的目录设计决策,而非本门禁的缺口。 +- 每个生成的事件或方法片段都带有其原始 JSDoc,而正文摘要不含标签。因此,源码编辑会同时刷新可读索引和签名旁展示的确切契约。 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 15039922c5..529c94c930 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-doc-tiers-and-budgets.md: ddbce540c68b9b35bb7e6a48b8db8abbc3a7a398 -2026-07-04-doc-tiers-and-budgets.zh.md: d3f08d337af34b5def41792232330180123dd585 +2026-07-04-doc-tiers-and-budgets.md: 9993eda6b0c7f1f8e5908dbd85fcfb4e5c6b3d1d +2026-07-04-doc-tiers-and-budgets.zh.md: d708919217a086928951c4acfed57985194301c4 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index d3f08d337a..d708919217 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -1,4 +1,4 @@ -# RFC: 文档分层、预算与上限门禁 +# Agent Note: 文档分层、预算与上限门禁 Status: implemented @@ -6,14 +6,14 @@ Status: implemented ## 问题 -尽管已有写作指导,常设文档仍然积累了重复的规则、重述的事故、重复的包(package)映射和陈旧的 RFC 摘要。仅靠评审无法阻止这种膨胀,因此仓库需要在文档分类体系之外增加一道机械化的预算约束。 +尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事件、重复的包映射,以及陈旧的 Agent Note(agent 决策记录)摘要。仅靠评审无法阻止这种增长,因此仓库需要在文档分类之外再配一套机械预算。 ## 决策 -- **分层分类体系,每条事实只有一个归属地。** [docs/AGENTS.md](../../../AGENTS.md) 是文档标准:它为每个 Markdown 层级指定唯一职责(常设指令、系统地图、类型目录、决策记录、事故叙事、实操手册、逐包契约、生成目录、工作流),禁止在归属层级之外重述事实(应以链接代替),并附带一份在撰写或评审任何文档时使用的冗余检查清单。 -- **窄范围、硬约束的预算门禁。** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 加入 `doc-sync`(文档同步门禁):[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 中列出的每篇文档都必须低于其字数上限(`wc -w` 语义,整个文件),且受预算约束的文件如果缺失也会导致门禁失败,防止重命名后预算被静默遗留。范围刻意限定为容易膨胀的常设文档:根目录和子树的 `AGENTS.md`、`architecture.md`、`packages/README.md`,以及它们将内容分流到的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、RFC 和 package README 不设预算:当每一行都是事实时,长度是合理的,由评审加冗余检查清单来管控。 +- **每项事实只归属一处的层级分类。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:它为每种 Markdown 层级分配单一职责(常设指令、系统图、类型目录、决策记录、事件故事、操作指南、各包契约、生成式目录、工作流),禁止在事实归属层级之外重复陈述(应改为链接),并包含编写或评审任何文档时使用的赘余检查清单。 +- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其字数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。 - **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。推进机制与[翻译配对的 `required` 清单](2026-07-02-bilingual-docs-and-pairing-gate.md)相同。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。 -- **轻量工作流 skill(技能),契约在文档中。** [.agents/skills/dsh-doc-standards](../../../../.agents/skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯修复工作流,并将文档标准作为真源,与 [dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md) 对 i18n 契约的分工方式一致。 +- **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为事实来源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml index 7aba38dbc9..7582c5e480 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-persistence-log-catalog.md: d92666fc8bafcf42542940fe399d9a3ece20c173 -2026-07-04-persistence-log-catalog.zh.md: a3524b251522050567be240f28944872e725a24e +2026-07-04-persistence-log-catalog.md: 1529f41b485c1bc8ca029c0a9264574fa7a886a0 +2026-07-04-persistence-log-catalog.zh.md: 24ac6caf8331c579828e707e289f28827caa072f diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md index a3524b2515..24ac6caf83 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC: 生成式持久化日志事件目录 +# Agent Note: 生成式持久化日志事件目录 Status: implemented @@ -6,19 +6,19 @@ Status: implemented ## 问题 -`SessionEventMap` 是磁盘上的词汇,但其声明分散在拥有它的 session 包(package)与声明合并中。生成式持久化目录是所有事件与 payload 的唯一参考;手工维护的表格会漂移,已被移除。这些记录不是 Cordis 事件,观察者通过单一的 `session/event` 总线事件接收它们,因此 Cordis 目录无法覆盖。生成器发现所有声明,doc-sync(文档同步门禁)的新鲜度门禁拒绝遗漏或陈旧的输出。 +`SessionEventMap` 是磁盘格式的词汇,但其声明分散在所属 session 包和声明合并中。生成式持久化目录是所有事件、各自完整 payload 声明与源码 JSDoc,以及共享 `SessionEvent` 信封的唯一参考;手工维护的表格会发生漂移,因此被移除。这些记录不是 Cordis 事件——观察者通过唯一的 `session/event` 总线事件接收它们——所以 Cordis 目录无法覆盖。生成器会发现所有声明,文档同步新鲜度门禁会拒绝遗漏或陈旧输出。 ## 决策 从源码生成 `docs/persistence-catalog.md`,配合新鲜度门禁,作为第四个参考面:持久化会话日志可以包含的*记录*,与 Cordis 目录(接线)、核心数据结构(词汇)和工具目录(工具)互补。 -`gen-persistence-catalog.ts` 使用 TypeScript AST 扫描所有拥有方与声明合并的 `SessionEventMap`。它渲染源码 JSDoc、payload 类型、派生的 surface 徽章、参考链接与源码位置。doc-sync 新鲜度检查会拒绝任何词汇变更后未重新生成目录的情况。 +`gen-persistence-catalog.ts` 使用 TypeScript AST 扫描每个所属及声明合并的 `SessionEventMap`。它从前置 JSDoc 开始渲染每个成员,直至完整的 payload 类型,保留嵌套属性注释且只移除其容器缩进;同时粘贴构成持久化信封的所属 `SessionEventType`、`SurfaceEventType`、`SurfaceOp` 和 `SessionEvent` 声明。派生的 surface 徽章、参考链接和源码位置仍位于声明块之外。文档同步新鲜度检查会拒绝目录尚未重新生成的词汇或信封变更。 具体选择: -- **JSDoc 完整性,强制执行。** 每个成员必须带有描述性文字——JSDoc 即为目录条目,与 Cordis 目录对总线事件施加的强制机制相同。成员上的 `@mode` 标签是硬错误:dispatch mode 属于 Cordis 总线事件,日志事件没有 mode,该标签会被误读为「此事件以模式 X 在总线上触发」。违规项聚合为一条错误,列出所有违规者。 +- **强制保证 JSDoc 完整性。** 每个成员和渲染出的信封类型都必须带有描述正文,完整的源码 JSDoc 会在目录中保持附着于其声明。`@mode` 标签是硬错误:分派模式属于 Cordis 总线事件,持久化记录没有这种模式。所有违规会汇总为一条错误,列出每个违规项。 - **surface 徽章由派生得出,而非手工列举。** `SurfaceEventType`(产生 LLM(大语言模型)消息且可能携带 `surfaceOp` 的子集)从拥有方包中的 union 声明解析;如果 union 成员命名了一个未声明的事件,则为硬错误(否则陈旧的 union 成员会静默地不标注任何内容)。其余一律渲染为 **log-only**。 -- **专用围栏。** payload 块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 识别并跳过它,不计入 opt-out 比例——与 `ts cordis-catalog` 的处理方式相同(裸 payload 片段无法独立编译)。 +- **专用围栏。** 声明块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 会识别并跳过这些块,将其排除在退出检查比例之外——处理方式与 `ts cordis-catalog` 相同(这些声明引用所属模块中的类型,无法独立编译)。 - **仓库范围。** 目录枚举本仓库中的包,与兄弟文档的 packages-only 范围一致;下游插件可以合并更多事件类型,它们在设计上不在目录范围内。遍历过程用硬错误保护自身假设:拥有方的顶层 `interface SessionEventMap` 必须是 `@deepseek-ai/dsh-session` 中唯一的导出声明(无关的、局部的或同名重复的接口不能被当作磁盘词汇编入目录);任何声明不得携带 `extends`(继承的键会加入 `keyof SessionEventMap` 却没有对应的目录行);每个成员必须是带有显式 payload 类型的属性签名(方法形式的成员会加入 `keyof` 却在静默遍历中被漏过);跨声明的重复成员也会失败。 本方案取代了手工副本:session.md 的 `hook/*` 表格、精简版 README 的事件表格、hook-protocol README 的 payload 条目列表,以及 session README 的名称列表现在链接到目录,而不再重述 payload(周围的语义说明文字保留原位)。hook-protocol 合并成员上的两个误加的 `@mode emit` 标签已被移除——新门禁将它们作为类别错误拒绝。 @@ -30,7 +30,7 @@ Status: implemented ## 后果 -- 目录不会漂移:词汇变更若未反映在已提交的文件中,`verify-persistence-catalog` 会在 pre-push 钩子和 CI 中失败;新合并的事件若缺少 JSDoc,生成器直接报错——插件不再能添加未文档化的磁盘记录类型。 -- 事件描述有唯一归属地,即声明处的 JSDoc;JSDoc 写得单薄,目录条目就单薄,这迫使作者在源头做好文档。 +- 目录不会发生漂移:提交文件未反映的词汇或信封变化会使 `doc-sync` 和 CI 中的 `verify-persistence-catalog` 失败,而没有 JSDoc 的新增合并事件会直接使生成器失败——插件不能再添加未记录的磁盘记录类型。 +- 事件正文只有一个归属,即声明处的 JSDoc;目录会保留该 JSDoc 和所有嵌套字段注释,不会将其扁平化或复述。 - `SurfaceEventType` union 现在对文档具有结构性承载作用:重命名事件而不更新 union(或反过来)会导致生成器失败,而不仅仅是编译器失败。 - 徽章派生假设 union 始终是一组封闭的字符串字面量且只有一个拥有方;如果重构偏离了这一形状,必须在同一个变更中更新生成器。 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml index 188ebf1019..ddd9099b22 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-uniform-rfc-format.md: 9688ad6566e88372508bbfd8d032ab6d02568716 -2026-07-05-uniform-rfc-format.zh.md: eab2505e64b813f698b4356bc52a796cbd66b948 +2026-07-05-uniform-agent-note-format.md: 06082251c1b96c90ed470d84224662e00e29791b +2026-07-05-uniform-agent-note-format.zh.md: 61ade75f179d64fa73390dc85bcd47c30deb112a diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md index 1a6aa40477..06082251c1 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-05-uniform-agent-note-format.zh.md) + ## Problem Agent Note paths encoded lifecycle and class, but file contents still mixed headings, status formats, ADR and proposal templates, and proposal-era sections in implemented records. Authors copied whichever neighbor they found, and lifecycle moves could skip the required rewrite because no gate enforced an in-file contract. diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md index eab2505e64..61ade75f17 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md @@ -1,30 +1,30 @@ -# RFC: RFC 的统一受门禁约束的文件内格式 +# Agent Note: Agent Note 的统一受门禁约束的文件内格式 Status: implemented -[English](2026-07-05-uniform-rfc-format.md) | 中文 +[English](2026-07-05-uniform-agent-note-format.md) | 中文 ## 问题 -RFC 的路径已经编码了生命周期和分类,但文件内容仍然混杂着不同的标题风格、状态格式、ADR 与 proposal 模板,以及已实现记录中残留的 proposal 时期的章节。作者随手复制找到的任何邻近文件,生命周期迁移时可以跳过必要的改写,因为没有门禁强制执行文件内契约。 +Agent Note(agent 决策记录)的路径编码了生命周期和类别,但文件内容仍混杂着不同标题、状态格式、ADR 与提案模板,以及已实现记录中的提案阶段章节。作者会复制随手找到的相邻文件,而生命周期迁移可能跳过必要的改写,因为没有门禁强制执行文件内契约。 ## 决策 -[README.md § The file format](../../README.md#the-file-format) 即文件内契约:头部块(`# RFC: <title>` 加上无日期、与所在文件夹一致的 `Status:` 枚举,唯一的正文内容是 rejection reason);按生命周期区分的正文骨架(所有阶段都以 `Problem` 开头;`proposed/` 中为 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 中为现在时态的 `Decision`/`Consequences` 且禁止 proposal 时期的标题;`rejected/` 中冻结 proposal 形态);必须包含 `Alternatives considered` 章节;以及规范的章节词汇表,其间的自定义技术章节保持自由形式。`pnpm run verify-rfc-format`([scripts/verify-rfc-format.ts](../../../../scripts/verify-rfc-format.ts))作为 `doc-sync`(文档同步门禁)的一环强制执行每条机械化条款,因此生命周期迁移时跳过改写现在会让 CI 失败,而不是依赖评审者的记忆。 +[README.md § 文件格式](../../README.md#the-file-format)是文件内契约——头部块(`# Agent Note: <title>`,加上无日期且与文件夹一致的 `Status:` 枚举,其中只有拒绝原因可作为额外内容)、各生命周期的正文骨架(所有文件均以 `Problem` 开篇;`proposed/` 使用 `Proposal`/`Acceptance criteria`/`Risks`;`implemented/` 使用现在时的 `Decision`/`Consequences`,并禁止提案阶段标题;`rejected/` 冻结提案形状)、强制的 `Alternatives considered` 章节,以及规范章节词汇;定制技术章节可在这些规范章节之间保持自由形式。`pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../../../scripts/verify-agent-note-format.ts))作为 `doc-sync` 的一部分强制执行每项机械规则,因此跳过改写的生命周期迁移现在会使 CI 失败,而不再依赖评审者记忆。 -整个语料库在定义格式的同一个变更中完成了规范化,这是预发布阶段的立场:没有过渡期,不容忍双格式并存。唯一的祖父条款针对内容而非格式:替代方案是记录下来的,不是凭空编造的;因此如果一篇格式定义前的 RFC 的替代方案无法从记录中重建,它会携带 `rfc-format: alternatives-not-recorded` 这条精确注释,门禁仅对日期早于本 RFC 的文件接受该注释。 +定义该格式的同一变更规范化了整个语料库——遵循预发布立场:不设过渡期,不容忍双格式。唯一受既有条款豁免的是内容,而非格式:替代方案只能记录、不能杜撰,因此若某份格式制定前的 Agent Note 无法从记录中还原替代方案,就会携带确切的 `agent-note-format: alternatives-not-recorded` 注释;门禁只对日期早于本文的文件接受该注释。 ## 曾考虑的替代方案 -- **完全刚性的模板**(每个生命周期一个固定章节序列,所有 RFC 重构以适配):否决。大型设计 RFC 包含八到十五个自定义技术章节(包拓扑、协议格式契约、schema),这些是承重内容而非漂移;刚性序列会立即强制破坏性改写,并永远带来与模板的对抗。 +- **完整的刚性模板**(每个生命周期使用固定章节顺序,重构每份 Agent Note 以适配):否决。大型设计 Agent Note 包含八到十五个定制技术章节(包拓扑、线协议、schema),它们是承载设计的内容,而非漂移;刚性顺序会迫使我们现在进行破坏性改写,并永远与模板较劲。 - **仅规范化头部**(H1 和 Status,正文不动):否决。债务标记指出的是*正文*的体裁分裂,让 `Context`/`Decision` 与 `Problem`/`Proposal` 无限期并存什么也解决不了。 -- **不设 Status 行**(文件夹本身就是状态;格式定义前最新的三篇 RFC(以及其中一篇的中文对侧文件)省略了该行):否决,保留自描述文件。省略 Status 行的动机是防止漂移,而将该行与文件夹做门禁校验即可消除漂移风险。 +- **不设 Status 行**(文件夹已经表示状态;格式制定前最新的三份 Agent Note 及其中一份的中文对应文件省略了该行):否决,保留文件的自描述性。通过门禁校验该行与文件夹一致,消除了原本促使我们删除它的漂移风险。 - **带日期的 Status**(`Status: implemented (accepted YYYY-MM-DD)`):否决。接受日期属于叙述性历史,写作规则将其排除在文档之外;文件名承载首次提出日期,git 承载其余信息;门禁能检查日期格式,但永远无法检查其真实性。 -- **裸 `# <title>` H1**:否决。`RFC: ` 前缀是语料库中的多数形式,且在文件脱离目录树被阅读时能自描述体裁;索引生成器会剥离前缀,因此索引行无论哪种写法都一样。 -- **`## What we give up` 作为 implemented 的结尾章节**(README 自身对 RFC 记录内容的措辞):否决。它只命名了代价,而诚实的后果章节同样记录这笔权衡换来了什么。 +- **裸 `# <title>` H1**:否决。文件脱离目录树单独阅读时,`Agent Note: ` 前缀能自描述其体裁,而格式门禁可防止它漂移。 +- **以 `## What we give up` 作为已实现记录的结尾**(README 对 Agent Note 所记录内容的原有表述):否决。它只点出成本,而诚实的后果章节也会记录取舍换来了什么。 - **只有约定没有门禁**(写下契约,靠评审强制执行):否决。slop checklist 已经通过约定禁止在 `implemented/` 中使用 spec 语气,而十九个文件展示了仅靠约定在此处能达到什么效果。 -- **独立的 `FORMAT.md` 契约文件**:最初的落地位置;在生成索引迁出到 [INDEX.md](../../INDEX.md) 之后折入 README.md:表格移走后 README 重新有了空间,一个前门同时承载布局、分类和格式,优于将契约拆分到两个文件。 +- **独立的 `FORMAT.md` 契约文件**:否决。由一个入口同时承载布局、分类和格式,比维护两个契约文件更易发现和维护。 ## 后果 -每篇 RFC 现在需要略多一些结构,而必须包含 `Alternatives considered` 章节是刻意的摩擦:一个没有记录被否决方案的决策,会招致 RFC 本来就是为了防止的重新争论。格式定义前的 RFC 如果替代方案无法重建,则永久携带祖父条款注释,这是记录上的诚实缺口,而非编造的理由。`doc-sync` 增加一道门禁,将 RFC 在生命周期文件夹之间迁移现在是迁移时的实际工作(即迁移本就欠下的正文改写),而非无人追踪的延后清理。三十九个债务标记已全部消除,由它们等待的模板所解决。 +现在每份 Agent Note 都需要稍多一些结构,而强制的 `Alternatives considered` 章节是有意设置的阻力:记录决策却不记录它胜过什么,会招致 Agent Note 本应防止的重新争论。无法还原替代方案的格式制定前 Agent Note 会永久保留既有条款注释——这是记录中诚实的缺口,而不是杜撰的理由。`doc-sync` 增加一道门禁;在生命周期文件夹之间移动 Agent Note 时,现在必须当场完成真正的工作(迁移本就应包含的正文改写),而不是推迟为无人跟踪的清理任务。三十九个债务标记已经消失,由它们一直等待的模板解决。 diff --git a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml index ed24da934e..966ac24d13 100644 --- a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-export-surface-jsdoc-gate.md: c2543fe50320da70c9d75bb4a16df3b110b50c47 -2026-07-06-export-surface-jsdoc-gate.zh.md: 98cb057cc1a661d2f51b8215609fc280d549c103 +2026-07-06-export-surface-jsdoc-gate.md: 93d8a41fc2ffb235de5c56ffeb5569bc95249392 +2026-07-06-export-surface-jsdoc-gate.zh.md: 64b4bcc620046f2c94a2ce3fbeb67e59ada888d4 diff --git a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md index 98cb057cc1..64b4bcc620 100644 --- a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md @@ -1,4 +1,4 @@ -# RFC: 导出表面 JSDoc 门禁 +# Agent Note: 导出表面 JSDoc 门禁 Status: implemented @@ -28,7 +28,7 @@ Status: implemented - **插件协议槽位。** 顶层的 `name`/`inject`/`reusable`/`Config` 常量和 `apply` 入口,以及插件类上的同名静态成员,属于框架协议:其形状由 Cordis 固定,模块文档注释加 `interface Config` 承载插件的真实语义。 - **构造函数**,与 Cordis 门禁一致:插件类由框架构造,类文档承载全部说明。 -`collectExportJsdocViolations()` 返回违规列表(CLI 在非空时以 1 退出),因此 `packages/core/agent/tests/verify-export-jsdoc.spec.ts` 中的负路径测试直接断言发现项,通过 fixture(测试前置数据)包驱动每一种拒绝和每一种豁免。 +`collectExportJsdocViolations()` 返回违规列表(CLI(命令行界面)在非空时以 1 退出),因此 `packages/core/agent/tests/verify-export-jsdoc.spec.ts` 中的负路径测试直接断言发现项,通过 fixture(测试前置数据)包驱动每一种拒绝和每一种豁免。 ## 曾考虑的替代方案 @@ -38,7 +38,7 @@ Status: implemented ## 后果 -- 新增导出不能在无文档的情况下合入:`verify-export-jsdoc` 使 `doc-sync` 失败,而 pre-push 和 CI 已运行该门禁。采纳时发现的 203 处缺口在同一个变更中补齐,门禁以绿色状态落地。 +- 新导出不能在缺少文档的情况下落地:`verify-export-jsdoc` 会使 `doc-sync` 和 CI 失败。采纳时发现的 203 处缺口已在同一变更中补齐,因此门禁以绿色状态落地。 - 导出函数必须标注返回类型(采纳时已全面满足,现在成为门禁依赖),并在 `@param` 需要命名参数时使用标识符参数。 - seam 文档是权威的:实现从其继承链继承文档,值得保留在实现上的行为说明是补充,而非必需。 - 门禁构建一个 `ts.Program`(约 6 秒)——唯一需要类型解析的文档门禁;在已编译文档片段的 `doc-sync` 内可以接受。 diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml index 18aecd24ad..728dd62166 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-generated-config-catalog.md: 77c2007d4b5cd28efcfe391c2de712db3b5c107e -2026-07-06-generated-config-catalog.zh.md: 4d9b190b770432111287cedee5992d5822197feb +2026-07-06-generated-config-catalog.md: f39f5138526d3278e839ee0053d5051bb8bc1c36 +2026-07-06-generated-config-catalog.zh.md: 8f08a7791916351df508c89f0c783dee2db17bae diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md index 4d9b190b77..8f08a77919 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md @@ -1,4 +1,4 @@ -# RFC: 生成式插件配置目录 +# Agent Note: 生成式插件配置目录 Status: implemented @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`scripts/gen-config-catalog.ts` 从每个插件声明的配置类型与 JSDoc 生成 [docs/config-catalog.md](../../../config-catalog.md),包含注入要求、引用类型链接和源码指针。包内局部类型被传递性地包含;workspace 和外部类型以链接或名称形式引用。确定性的 `--write` 和 `--check` 模式使提交到仓库的页面成为一个生成产物。 +`scripts/gen-config-catalog.ts` 根据各插件声明的 config 类型和 JSDoc 生成 [docs/config-catalog.md](../../../../docs/config-catalog.md),并包含注入要求、被引用类型的链接和源码位置。包内类型会以传递方式纳入;workspace 类型和外部类型则会链接或点名。确定性的 `--write` 和 `--check` 模式使提交页面成为生成产物。 此处采用纯 AST 生成是正确的,原因与 events/services catalog 相同,而与 tool catalog 不同:配置类型是静态声明,仓库中每个 schemastery schema 都是静态的 `z.object`/`z.intersect` 字面量,因此源码即全部真相——配置表面没有任何部分是运行时组合的。 @@ -34,7 +34,7 @@ Status: implemented ## 后果 -- catalog 不会漂移:源码变更而提交的文件未反映时,`verify-config-catalog` 在 pre-push 和 CI 中报错。未文档化的配置字段、无法解析的引用类型名、或 schema 键在配置类型中缺失,都会直接导致生成器报错。 +- 目录不会发生漂移:提交文件未反映的源码变化会使 `doc-sync` 和 CI 中的 `verify-config-catalog` 失败。config 字段未记录、被引用类型名无法解析,或 schema 键未出现在 config 类型中,都会直接使生成器失败。 - 配置行文现在有了声明处的强制函数:编写新配置字段意味着编写其 JSDoc,而该 JSDoc 将逐字成为 catalog 条目。 - 生成器对无法静态遍历的形状直接报错——别名化的包内配置导入、非 `object`/`intersect` 组合构建的 schema、未列入的全局类型名。引入此类形状时必须同时教会生成器(否则该形状不能进入仓库),这正是设计意图:catalog 始终是全部真相。 - `gen-cordis-catalog.ts` 导出其 JSDoc/指针辅助函数与 `LINK_MAP` 供复用,因此两个 catalog 以相同方式交叉链接类型,新增一条 link-map 条目同时服务于两者。 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index e324b52b3f..bb788d803d 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-node-engine-floor.md: f7764c408374a329c9390635e1b92ae9a4fd9dee -2026-07-06-node-engine-floor.zh.md: c46bf5168007b97878319e5f333b5bb1ab1d8f2a +2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd +2026-07-06-node-engine-floor.zh.md: 18e878bdfcc32938125ee46e742a42499e0b58ab diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index c46bf51680..18e878bdfc 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -1,4 +1,4 @@ -# RFC: 将 Node LTS 引擎下限提升至 22.19 +# Agent Note: 将 Node LTS 引擎下限提升至 22.19 Status: implemented @@ -15,7 +15,7 @@ Status: implemented 两个 Node 特性决定了源码运行时的门槛: - **`node:sqlite`**:`packages/session-persistence/session-persistence-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` 标志要求;在此之前,导入它会在加载时抛出异常。 -- **原生 TypeScript 类型剥离**:`packages/examples/stdio-demo/tests/built-bin.e2e.ts` 冒烟测试在纯 `node`(不用 tsx)下启动已发布的 `lib/bin.js`,并加载示例的 `.ts` 插件(`mock-llm.ts`、`echo-tool.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;在此之前需要 `--experimental-strip-types`。 +- **原生 TypeScript 类型剥离**——构建模式的 `examples/headless-agent/tests/keyless-smoke.e2e.ts` 冒烟测试使用纯 `node`(无 tsx)启动 `dsh-cli-demo` 已发布的 `lib/bin.js`,并加载示例的 `.ts` 测试适配器(`cli-mock-llm.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;更早版本需要 `--experimental-strip-types`。 这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的 package 声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 @@ -26,7 +26,7 @@ Status: implemented - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。 - CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每条分支都对源码图执行类型检查,并实际启动未构建的工作流 worker。 - built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。 -- 未来如果有依赖或源码 API 提高运行时下限,必须在同一个变更中同步修改 `engines.node`、兼容性矩阵和本 RFC。 +- 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note(agent 决策记录)。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml index 40132b2b97..d8ebe51c67 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-parallel-github-ci-gates.md: 6a9bd339304c8efac7a29bdbe745270ab9661396 -2026-07-06-parallel-github-ci-gates.zh.md: 24fdf27822aac7640fbeaf602fa8dd84277083fa +2026-07-06-parallel-github-ci-gates.md: 5c276f6a75936021369bc5ad9494c9aa6e4e3fc3 +2026-07-06-parallel-github-ci-gates.zh.md: f96606ba2b58b856b3833e758853e9fa3a62bff3 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index fef5852153..5c276f6a75 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-06-parallel-github-ci-gates.zh.md) + ## Problem The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every short leaf into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md index 24fdf27822..f96606ba2b 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md @@ -1,4 +1,4 @@ -# RFC: 并行 GitHub CI 门禁 +# Agent Note: 并行 GitHub CI 门禁 Status: implemented @@ -6,36 +6,45 @@ Status: implemented ## 问题 -keyless GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照回放、构建、包发布卫生检查、demo 冒烟测试与 built-bin 冒烟测试各自因不同原因失败,彼此不需要对方的运行时状态。将它们串成一条有序命令链,工作流的挂钟时间等于所有门禁之和;而把每个叶子门禁拆成独立的 GitHub job,则会重复 checkout、Node 设置、pnpm restore 和 install 工作,直到编排开销本身成为瓶颈。 +无密钥 GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照重放、构建、包发布卫生、demo 冒烟和已构建二进制冒烟会因不同原因失败,也不需要彼此的运行时状态。将它们作为一条有序命令链运行,会使工作流墙钟时间等于所有门禁耗时之和;而把每个短小叶子拆成独立 GitHub job,又会反复执行 checkout、Node 设置、pnpm 恢复和安装,直到编排开销成为瓶颈。 -难点在于产物边界。`publint`、`verify-node-next-types` 和 built-bin 冒烟测试需要构建出的 `lib/` 输出,而大多数门禁只需要源码和依赖。盲目扇出要么让这些产物消费方在 `pnpm run build` 输出声明文件和 bundle 之前就开始竞跑,要么在每个依赖产物的 job 中重复构建。 +随着 workspace 增长,原有的宽车道拆分不再满足这一平衡。PR(Pull Request)#404 合并时,Linux 的静态、覆盖率、快照和产物 job 分别耗时 148、195、94 和 230 秒;Windows 的静态和产物 job 分别耗时 251 和 482 秒。每个包都调用一次包管理器打包,主导了两个产物验证器的耗时;覆盖率在仅运行源码的套件前无谓地重建输出;CPU 密集型门禁则在静态与覆盖率车道内争用资源。 + +产物边界仍然承载关键约束。`publint`、`verify-node-next-types`、已编译不变量加载和已构建二进制冒烟测试都需要生成的 `lib/` 输出。分片不能让这些消费者抢在构建前运行,也不能用源码执行取代它们对已发布产物的信号。 ## 决策 -[CI](../../../../.github/workflows/ci.yml) 将 keyless 检查分组为若干宽粒度的主运行时 lane,外加一个兼容性矩阵。工作流文件拥有当前 lane 和运行时清单的定义权。 +下述生产拓扑已经成为历史,并由[基于证据采用更大的托管 runner](2026-07-22-evidence-based-larger-hosted-runners.md) 取代。更大 runner 的决策移除了其分片选择器和工作流 job;本文保留早期拓扑为何被实现的记录。 -每个 lane 委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),该脚本以有界并发调度独立门禁,并为每个门禁打印一个可归因的结果块。产物消费方依赖其所在 lane 内的一次 build,而兼容性 job 将类型检查与一次真实的未构建 worker 启动结合,以覆盖运行时特定的 loader 行为。 +[CI](../../../../.github/workflows/ci.yml) 将非 Windows job 的一分钟和 Windows job 的三分钟视为观测所得的性能目标,而非取消截止时间。托管 runner 的波动应留下完整计时证据和有用的失败日志,而不是取消本来正确的门禁。[串行跨平台 CI 参考](2026-07-21-serial-cross-platform-ci-reference.md)会在 Linux、macOS 和 Windows 上独立运行完整、未分片的主 Node 聚合,使优化后的车道清单不会成为自身完整性的唯一判据。 -生成的 `.sessions/` 日志和 `.doc-typecheck-*` 临时目录被 lint 忽略。聚合的本地 CI 模式仍在 lint 之后运行 demo 冒烟测试,而拆分后的 GitHub static lane 可以直接运行 demo 冒烟测试,因为 lint 已隔离在自己的 lane 中。 +在该拓扑中,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 是通用的有界调度器,GitHub 则为昂贵的门禁族提供显式分片名称。`scripts/static-shards.ts` 将静态门禁划分为基础、文档类型、API 契约、目录、正文、文档投影和文档构建等归属,并拒绝缺失或重复的门禁分配。Linux lint 使用互不重叠的 A-C、D-M、N-S、T-Z 包源码和包测试车道,Windows 则使用完整的包源码与包测试车道;两者都包含从 `.` 开始的仓库补集,使新增顶层目标无法消失在分片之间,并负责唯一一次跨文件重复检查。`scripts/coverage-shards.ts` 把每个 workspace 包恰好分配给一个源码覆盖率车道。目录过滤器保留尾部分隔符,因为 Vitest 位置过滤器按子字符串匹配,否则会纳入具有同名前缀的相邻项。每个覆盖率车道只包含其拥有的源码文件,重复运行穷尽式伴随拓扑测试,并且不先执行构建,因为从删除了所有生成式 `lib/` 的树开始,完整覆盖率套件仍可通过。 -构建输出在 Node 24 的产物 lane 中只生成一次。产物消费方(`publint`、`verify-node-next-types` 和 built-bin 冒烟测试)声明对 `build` 的依赖,因此没有 upload/download 交接,消费方也不可能在声明文件或 bundle 就绪之前抢跑。CI 覆盖率报告仅输出文本,本地覆盖率则保留 HTML 报告。 +快照重放使用两个显式多文件车道,以及大型 ACP(Agent Client Protocol)文件的八个场景分区。`scripts/snapshot-shards.ts` 拥有该清单,其测试会发现快照配置允许的每个文件。每个快照 job 在其 Linux runner 准备 Bubblewrap 的同时安装依赖,随后构建已发布运行时,并且只运行分配给它的重放表面。该套件保留五个子进程的有界并发,因为重放的大部分时间都在等待子进程协议 I/O。fixture(测试前置数据)守卫仍会在每个分区中检查完整 ACP 场景表。 -两个工作流都缓存 pnpm store。真实 API 工作流使用共享的有界 Vitest 文件池,而非为每组测试单独开一个 job。 +冷启动的独立文档类型检查会重建完整的项目引用图,因此专用文档类型车道只构建一次,再用这些声明检查 Markdown 块。Linux 文档车道使用 VitePress 的 MPA 构建,在观测所得的非 Windows 目标内保留页面渲染与死链接验证;单独的阻塞式 Windows 构建和生产站点车道保留已生成包与已发布站点检查,同时避免把两条关键路径放进同一个 job。 + +产物使用两个车道:一个元数据车道负责 `publint`、NodeNext 声明和已编译不变量加载,另一个负责已构建二进制冒烟。每个车道都会在其消费者之前自行构建。重复短时构建会消耗 runner 分钟数,但避免了上传/下载依赖,并使每个 job 的关键路径保持有界。 + +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 在进程内针对内存发布视图调用 publint 支持的 API;该视图由每份清单声明的文件和 npm 强制元数据文件构成。这样无需生成 103 次包管理器打包命令,也能保留 workspace 文件与已发布文件之间的区别。[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) 在真实包下暂存这些经过结构验证、由清单声明的 `lib/` 文件,再通过纯 Node 和 Cordis Loader 规范化导入已编译的自引用。若伴随项触及未声明的运行时 chunk,仍会失败。 + +兼容性车道会在每条声明支持的 Node 版本线上运行源码 worker 和 Zstandard 运行时冒烟。TypeScript 在专用的主 Node 24 车道中只检查一次源码图;在运行时兼容性 job 中重复同一编译器分析只会增加耗时,不会提供运行时特有信号。 + +工作流缓存 pnpm store,将每个不可变 ESLint 缓存的键绑定到其所属 lint 分片,为 Windows 测量保留原生 PowerShell,并保留一个聚合的 `all checks passed` 状态用于分支保护。Windows 复用三个穷尽式 lint 分区,并在共享 runner 设置后组合基础/目录/正文门禁与文档类型/API 契约门禁;只有调度方式与 Linux 分区不同。Windows 构建和生产站点验证继续阻塞,而更广泛的 Windows 静态、lint 和产物矩阵仍为观察性检查。 ## 曾考虑的替代方案 -- **在 Node 矩阵中保留完整串行链**:最容易推理,但会重复执行不产生 Node 版本特定信号的仓库级门禁,且让每个 PR 等待所有门禁的总和。 -- **每个门禁作为独立 GitHub job 运行**:最大化 GitHub 可见的扇出,但产生过多 check,且对运行时间短于 runner 准备时间的门禁而言,重复的 setup/install 开销得不偿失。 -- **将构建产物上传给依赖产物的 job**:在多 job 间保持正确性,但增加了 artifact upload/download 时间,且当产物消费方可以在主 job 内通过本地依赖排序运行时,工作流仍然过宽。 -- **并发运行 `typecheck` 与 `build`**:向调度器暴露更多工作,但两个命令都调用 `tsc -b`;在它们之间共享增量构建状态是一场不必要的竞争,换来的挂钟收益很小。 -- **使用无界的真实 API e2e 并行度**:否决。该套件包含大量真实模型/工具场景;worker 池需要一个显式的 `DSH_E2E_MAX_WORKERS` 上限,使 CI 和本地运行都能扇出,同时不会把配额或资源问题隐藏在不稳定的限流失败背后。 +- **保留宽车道**:最大限度减少工作流 YAML,但会保留观测到的数分钟反馈周期。 +- **让每个叶子门禁分别成为 GitHub job**:最大化扇出,但短小的生成器和正文检查准备 runner 的时间会超过检查仓库的时间。 +- **向产物消费者上传一次构建**:避免重复编译,但上传/下载和依赖调度会延长墙钟时间;干净构建足够短,可以在有界车道内重复。 +- **在两个发布门禁中保留包管理器打包**:把清单选择委托给 pnpm,但会重复启动 200 多个包管理器进程。清单结构门禁加发布视图 fixture 使优化后的清单契约显式化,并会在存在磁盘上有但未发布的依赖时失败。 +- **在覆盖率前保留构建**:提供源码套件已不再消费的生成输出;干净树覆盖率证明表明这只是纯粹的延迟。 +- **在每个 Node 版本上执行类型检查**:重复编译器工作,而兼容性冒烟已经验证实际的 Node 特有加载与压缩行为。 ## 后果 -PR 反馈以少量 GitHub check 的形式呈现,每个宽粒度 job 内部包含结构化的逐门禁日志块。这将 runner 设置开销控制在有限范围内,并保持 Actions UI 紧凑,代价是失去了每个叶子门禁独立的状态标记。 +上述分片清单和矩阵 job 不属于当前仓库契约。取而代之的更大 runner 决策在单个进程中保留完整主清单,并以串行套件作为独立完整性判据。 -宽 lane 拆分比单一主 job 更频繁地重复 checkout、setup 和 install。这一设置开销是有意为之的:在 GitHub 托管 runner 上,将 lint、覆盖率和快照回放放在同一个进程池中运行会严重超额占用 CPU,以至于单 job 的关键路径比重复设置还要长。 +优化后的发布验证器依赖由 `verify-package-invariants` 强制执行的清单 `files` 契约。如果发布规则超出该契约,结构门禁和两个暂存视图必须一起变化。 -这种拆分引入了一项维护义务:当 `package.json` 新增或移除一个应纳入 CI 的门禁时,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 需要添加或删除对应的叶子。这一义务是有意为之的,因为该 runner 是同一套门禁词汇的并行执行计划,而非独立的质量策略。 - -兼容性信号比主 Node 24 信号更窄。它证明源码图在每个声明支持的运行时上都能通过类型检查,且真实的未构建 workflow-worker 启动路径能够执行,而不必重复文档、覆盖率、发布、快照回放以及那些不因 Node 版本而异的无关冒烟测试。 +兼容性 job 不再声称 TypeScript 本身已在每个 Node 运行时下执行。它们证明 Node 22、24 和 26 上对运行时敏感的源码加载,而主运行时负责唯一一次源码图类型检查。 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index e3e1c0d234..b228a3a455 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-parallel-pre-push-gates.md: cf032f8dfedd88a7d6be87999fd9786a9efbb2e8 -2026-07-06-parallel-pre-push-gates.zh.md: 776faf1f46c6490f1935727612225414dd258aa3 +2026-07-06-parallel-pre-push-gates.md: f2e8f0054e595be20a320ec7095f0fe674eb93c6 +2026-07-06-parallel-pre-push-gates.zh.md: 06cd2fc41ca9a2cc2546fe85de82c8198f9573c3 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 87b1c0847b..f2e8f0054e 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-06-parallel-pre-push-gates.zh.md) + The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. ## Problem diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 776faf1f46..06cd2fc41c 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -1,42 +1,33 @@ -# RFC: 并行 pre-push 门禁 +# Agent Note: 并行 pre-push 门禁 Status: implemented [English](2026-07-06-parallel-pre-push-gates.md) | 中文 +本记录中的本地 hook 部分已由[快速本地 Git hook](2026-07-22-fast-local-git-hooks.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。 + ## 问题 -pre-push 钩子是分支离开本地机器前的最后一道检查点,因此它的挂钟时间直接影响贡献者是否愿意保持启用并信任其信号。Lefthook 已经能并行运行顶层 job,但 `pnpm run hygiene` 和 `pnpm run doc-sync` 等聚合 job 在单个 job 内部隐藏了长串的顺序执行链。钩子因此可能在配置上看似并行,实际仍在等待那些成员彼此独立却串行执行的子命令。 - -将这些成员直接展平到 `lefthook.yml` 只能解决本地钩子的问题。CI 面临同样的调度问题,而在 YAML 中复制一长串叶子列表会让未来的脚本改动有两处可能漂移。 - -`publint` 在更低一层也有同样的形态。每个包(package)独立地根据自身 manifest(元数据清单)和构建产物做 lint,但 runner 按顺序遍历所有包。在本仓库中,这意味着一个包发布门禁的耗时与包的数量成正比,尽管各检查之间并不共享可变状态。 +文档同步等聚合 job 隐藏了很长的串行链,其成员只读且相互独立。在工作流 YAML 中重复这些叶子清单,会使未来脚本变更有多个位置可以发生漂移;而串行运行包发布检查,会使一道门禁的耗时与包数量成正比。 ## 决策 -[lefthook.yml](../../../../lefthook.yml) 保留一个名为 `full check` 的 pre-push job,运行 `pnpm run check:pre-push`。该 package 脚本委托给 [scripts/run-gates.ts](../../../../scripts/run-gates.ts),即 CI 使用的同一个有界调度器。 +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,遵守产物依赖,缓冲可归因的输出,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 -`pre-push` 模式展开为以下叶子门禁:单元测试套件、快照测试套件、构建、`hygiene` 成员、`doc-sync` 成员,以及 module-graph 新鲜度。叶子列表保持与 package 脚本相同的门禁词汇,包括 RFC 分类和 RFC 格式,同时 runner 并发调度独立检查,并为每个门禁打印一个计时/输出块。 +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 -构建门禁使钩子在干净的 worktree 上也能自足运行。`publint` 和 `verify-node-next-types` 等待构建产物就绪,而仅依赖源码的门禁继续并行执行。 - -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages/<group>/<pkg>` 发现包列表,并使用大小取自 `availableParallelism()` 的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可为资源配置不同的本地机器和 CI runner 设定或提高 worker 数量上限。结果按包缓冲,并以确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 - -聚合 package 脚本仍然是临时本地运行的真源。调度器是对其成员门禁的并行执行计划,而非替代词汇。 +各门禁的包脚本仍是临时本地运行所用的词汇。`hygiene` 继续作为聚合 `&&` 链,而 `doc-sync` 在调度器中拥有其成员列表([通过门禁调度器运行 doc-sync](2026-07-21-doc-sync-through-gate-scheduler.md))。 ## 曾考虑的替代方案 -- **在钩子中保留聚合的 `hygiene` 和 `doc-sync` job**:配置更简单,但 pre-push 的大部分挂钟时间仍然消耗在 lefthook 看不到也无法调度的串行命令链内部。 -- **为每个叶子门禁声明一个 lefthook job**:通过 lefthook 原生 job 模型暴露并行性,但会让钩子文件承载一长串成员列表,CI 无法复用。 -- **要求开发者在推送前手动构建**:可以省去一个钩子门禁,但会导致 `publint` 在干净 worktree 上失败,并把最后的本地检查点从可运行的检查降级为一种约定。 -- **在 shell 脚本中使用后台子命令**:能并行化工作,但会丢失 lefthook 的 job 名称、逐 job 计时和失败分组,且信号处理更难推理。 -- **为每个包声明一个 publint lefthook job**:暴露最大并行度,但会让钩子变成一份手动维护的包清单,恰好在新增包时漂移。 -- **以无界并发运行 publint**:仅在小型机器上以赌注方式最小化耗时,代价是进程数、内存压力、包 tarball 创建和日志可读性的风险。 +- **保持聚合 job 串行**:执行更简单,但墙钟时间等于各独立检查之和,并重复启动命令包装器。 +- **每个叶子门禁声明一个 CI job**:暴露最大工作流并行度,但会重复 checkout、设置和安装开销,并在 YAML 中复制调度器清单。 +- **在 shell 脚本内后台运行子命令**:可以并行处理,但会失去各门禁计时、确定性的失败分组和直接的信号处理。 +- **每个包声明一个 `publint` job**:暴露最大包级并行度,但会创建手工维护的包清单,包发生变化时就会漂移。 +- **以无界并发运行 `publint`**:只有通过拿进程数、内存压力、包 tarball 创建和可读日志冒险,才能最大限度缩短小型仓库的耗时。 ## 后果 -钩子的关键路径变为最慢的单个真实门禁,而非隐藏门禁链的总和。Lefthook 报告一个 `full check` job,runner 在该 job 内部报告逐门禁计时,因此本地检查点偏慢时仍能指向主导耗时的那个门禁。 +由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。代价是维护一个具有显式模式清单的定制调度器。 -钩子文件保持简短,重复的成员列表集中在 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中,CI 和 pre-push 可以共享。代价是引入一个自定义调度器脚本(而非纯 lefthook 配置),外加本地 pre-push 路径中的一次构建。 - -`publint-all.ts` 变为异步代码,缓冲命令输出而非实时继承 stdio。收益是包级别的并行性、稳定的输出顺序,以及一个用于资源调优的环境变量。 +`publint-all.ts` 采用异步执行并缓冲命令输出,而不是实时继承 stdio。换来的是具有稳定输出顺序的包级并行,以及用于资源调节的单一环境变量。 diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml index 69cfd7e0db..4234c01a0c 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-readme-known-limitations-gate.md: 1aad1d40285f295833c2b1bd7d5c2e71bb780f34 -2026-07-10-readme-known-limitations-gate.zh.md: 4b3904fac3f980f9d005907775618cd28778a6fe +2026-07-10-readme-known-limitations-gate.md: 2ca1168d795692730d17b6ab23dd113e8be277e5 +2026-07-10-readme-known-limitations-gate.zh.md: 7968a30b996b931169bbe2678ec1500ff86ef577 diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md index 4b3904fac3..7968a30b99 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md @@ -1,4 +1,4 @@ -# RFC: 在每个 package README 中设置受门禁保护的 Known Limitations 章节 +# Agent Note: 在每个 package README 中设置受门禁保护的 Known Limitations 章节 Status: implemented @@ -6,15 +6,15 @@ Status: implemented ## 问题 -[文档标准](../../../AGENTS.md)将限制事项指定在 package README 中记录。如果没有统一的格式,缺失的章节无法区分「经审计确认无此内容」与「忘了写文档」,而标题写法不一致也会妨碍全仓库搜索。 +[文档标准](../../../../docs/AGENTS.md)规定限制项归属包 README。没有共享形状时,缺少章节无法区分“经审计确认没有限制”与“忘记编写文档”,不同的标题还会妨碍全仓库搜索。 ## 决策 -`packages/<group>/<pkg>/package.json` 下的每个包(package)manifest(元数据清单)都有一个同目录的 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。该章节的条目记录该包拥有的持久性消费方缺口与非显而易见的维护者约束;常规清理工作仍留在源码 TODO 或所属 RFC 中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从 manifest 推导包集合,拒绝缺少 README 的情况,并要求恰好有一个规范的 h2 标题且至少包含一个顶级条目。近似标题(如 "Limitations"、"Deferred"、"What is NOT here" 或 "Non-goals")会导致失败。 +`packages/<group>/<pkg>/package.json` 下的每份包清单都有一个同级 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。其项目符号记录由该包拥有的持久消费者缺口和不明显的维护者约束;普通清理仍留在源码 TODO 或所属 Agent Note(agent 决策记录)中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从清单推导包集合,拒绝缺失 README,并要求恰好一个规范 h2 且至少包含一个顶层项目符号。“Limitations”“Deferred”“What is NOT here”或“Non-goals”等近似标题都会失败。 如果一个包确实没有需要声明的限制事项,则将其列入 `NO_LIMITATIONS` 并省略该章节。新增限制事项时须移除该条目;重命名或移除条目会失败,因为每个条目都必须对应一个被扫描的包。 -门禁检查的是存在性、格式与白名单。覆盖面和准确性由文档标准与 [prose 标准](../../../../.agents/skills/dsh-prose-standard/SKILL.md)下的评审负责。常设规则见 [packages/AGENTS.md](../../../../packages/AGENTS.md)。 +门禁检查存在性、形状和允许列表。按照文档与[正文](../../../skills/dsh-prose-standard/SKILL.md)标准进行的评审负责覆盖面和准确性。常设规则位于 [packages/AGENTS.md](../../../../packages/AGENTS.md)。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml index 4a0af0834e..903114914c 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-package-model-experience-contract.md: 28a1c75ca6f451b4ca2f6da6abf36f72c5d43b31 -2026-07-12-package-model-experience-contract.zh.md: 77fa7a67847ed186b65db57b6dda378a93b94d92 +2026-07-12-package-model-experience-contract.md: 92a8e5a1a81d00dae085e4af89456896373058e6 +2026-07-12-package-model-experience-contract.zh.md: d81e87f1cb52cc77800ad5e6256796aa389f3215 diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md index dd986f3661..92a8e5a1a8 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-12-package-model-experience-contract.zh.md) + ## Problem A package README can explain APIs and runtime mechanics without answering the questions that dominate an agent harness's behavior and cost: what from this package reaches a model request, under which conditions, how long those tokens remain, and whether later requests preserve a reusable KV-cache prefix. The omission is especially hard to audit in a plugin architecture. A consumer may turn a backend result into a tool message, a policy plugin may replace success with an error, compaction may remove old history, and an agent-scoped registration may change one agent's prompt or schemas while leaving every other agent unchanged. Reading only the nominally model-facing packages therefore misses real context effects, while reading source across every dependency is too expensive for routine review. diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md index 77fa7a6784..d81e87f1cb 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md @@ -1,4 +1,4 @@ -# RFC: Package Model Experience 契约 +# Agent Note: Package Model Experience 契约 Status: implemented @@ -6,28 +6,28 @@ Status: implemented ## 问题 -一个 package(包)的 README 可以解释 API 和运行时机制,却不回答那个主导 agent harness(智能体框架)行为与成本的问题:本 package 中有什么内容会进入模型请求、在什么条件下进入、以及这些 token 会保留多久。在插件架构中,这一缺失尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能把成功替换为错误,上下文压缩(context compaction)可能移除旧历史,agent 作用域的注册可能改变某个 agent 的提示词或 schema 而其他 agent 不受影响。因此,只阅读名义上面向模型的 package 会遗漏真实的上下文影响,而跨所有依赖阅读源码对日常评审来说又太昂贵。 +包 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的问题:该包的哪些内容会进入模型请求、在什么条件下进入、这些 token 会保留多久,以及后续请求是否会保留可复用的 KV cache 前缀。在插件架构中,这种遗漏尤其难以审计。消费者可能把后端结果转为工具消息,策略插件可能以错误取代成功结果,压缩可能移除旧历史,而 agent 范围的注册可能改变某个 agent 的 prompt 或 schema,却不影响其他 agent。因此,只阅读名义上面向模型的包会遗漏真实的上下文效应,而在每次常规评审中跨所有依赖阅读源码又成本过高。 ## 决策 -每个具有面向模型或模型相邻契约的 workspace package README,在末尾、`## Known Limitations and Deferred Work` 之前放置规范的 [Model Experience 章节](../../../cookbook/adding-a-package.md#4-write-the-package-readme);位于 no-limitations 允许列表上的 package 以 Model Experience 本身作为末尾章节。经审计确认为模型无关的通用 package 通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 +每个具有面向模型或邻近模型契约的 workspace 包 README 都以规范的[模型体验章节](../../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme)收尾,位置紧邻 `## Known Limitations and Deferred Work` 之前;位于“无限制项”允许列表中的包则以模型体验本身结尾。经审计确认与模型无关的通用包通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 -具有直接、条件性、有上限、生命周期性、多表面或辅助模型效应的 package,每个上下文表面使用一个 H3。每个 H3 说明相关模型接收到什么内容以及何时接收,然后对 token 效应进行分类。由 package 拥有的稳定文本逐字引用:系统提示词行文和其他长字面量使用嵌套 H4 加 `markdown` 围栏,短字面量则以行内形式呈现并使用命名插值占位符。工具 schema 表面链接到生成的[工具目录](../../../tool-catalog.md)中对应的锚定章节,仅说明组合或配置差异;仅在运行时定义的工具则解释为何目录中未收录。数据依赖和提供方拥有的文本以摘要形式描述。agent 作用域的可见性须显式说明;当作用域可以隐藏其中一个而不影响另一个时,提示词表面与 schema 表面保持分开。 +具有直接、条件式、有上限、全生命周期、多表面或辅助模型效应的包,为每个上下文表面使用一个 H3。每个表面包含三个有序 H4 字段——`What the model sees`、`Token effect` 和 `KV Cache effect`——每个字段都以一个正文段落开头。cache 字段区分仅追加增长、稳定重复前缀、替换先前 token,以及独立模型请求;它点明由包拥有、且能在新内容追加前改变请求的每项配置、范围、生命周期、压缩或路由变化。“Does not invalidate”表示该包保留一个已经可复用的前缀,并非承诺 provider 一定命中 cache 或保留某段时间。由包拥有的稳定文本按原文精确引用:system prompt 正文和其他长字面量在引入它们的字段下使用带标题的 H5 加 `markdown` 围栏,通常位于 `What the model sees`;短字面量则以内联形式保留,并点名插值占位符。工具 schema 表面链接生成式[工具目录](../../../../docs/tool-catalog.md)中带锚点的章节,并且只陈述组合或配置增量;仅运行时定义解释目录为何省略它们。依赖数据和由 provider 拥有的文本采用摘要。agent 范围的可见性须显式说明;当范围可隐藏 prompt 与 schema 中的一者而不影响另一者时,两种表面保持分离。 -没有模型上下文效应的 package,或其路径完全由另一个 package 渲染的 package,使用验证器审计过的单句形式:`None, as ` 或 `Indirectly, through `。纯传输和无密钥的测试支持 package 在不创建模型绑定内容时使用 none 形式。提供方后端即使对数据进行上限或过滤,也使用 indirect 形式;组装 bundle 在命名子 package 拥有全部效应时同样使用 indirect 形式。这些句子定位贡献所在,而不重述消费方的内容。结构化章节同样只记录 package 自身拥有的输入、变换和差异。 +没有模型上下文效应的包,或某条路径完全由另一个包渲染的包,使用验证器审计过的短格式:一句以 `None, as ` 或 `Indirectly, through ` 开头的句子,随后是一个 `KV Cache effect` H4 和一个正文段落。纯传输包和无密钥测试支持包若不创建任何进入模型的内容,就使用 none 格式。provider 后端即使会限制或过滤数据也使用 indirect 格式;具名子项拥有全部效应时,接线 bundle 也使用该格式。这些章节定位贡献并声明不会直接使 cache 失效,同时不重复陈述消费者。结构化章节同样只记录由包拥有的输入、变换和增量。 -`verify-package-readme-model-experience` 发现 package manifest(元数据清单)并验证三种分类、规范的末尾章节顺序、必填字段、具体字面量证据、嵌套逐字块和锚定的工具目录链接。它在 `doc-sync`(文档同步门禁)和并行门禁运行器中执行。覆盖面、链接相关性和事实准确性仍由评审把关。 +`verify-package-readme-model-experience` 发现包清单,并验证三种分类、规范末尾章节顺序、确切字段标题深度与顺序、非空字段段落、逐字块的 H5 归属、具体字面量证据,以及带锚点的工具目录链接。它在 `doc-sync` 和并行门禁 runner 中运行。评审仍负责覆盖面、链接相关性和事实准确性。 ## 曾考虑的替代方案 - **只记录注册提示词或工具的 package**:否决。后端、策略插件、适配器、持久化、作用域和压缩都会改变 token 的内容或生命周期,却不拥有面向模型的 schema。 - **从源码生成一份集中式上下文成本目录**:否决。AST 能找到注册点,但无法推断语义条件,如历史保留、输出截断、父子可见性或辅助模型边界。package README 是实现本地的契约;集中副本会增加又一个漂移面。 - **要求给出精确 token 数**:否决。精确数量取决于所选模型的 tokenizer、适配器序列化方式、配置和运行时数据。稳定的契约是增长形状:每请求固定、每调用条件性、保留、替换、有上限或零直接影响。 -- **使用三列表格**:否决。精确的源文本和条件性结果形状使单元格密集且难以扫读。重复的子章节为每个上下文表面提供可读的纵向空间,同时保留相同的字段。 +- **使用表格**:否决。精确源码文本和条件式结果形状会使单元格密集而难以扫读。重复的小节在保留相同字段的同时,为每个上下文表面提供易读的纵向空间。 - **允许所有零影响 package 省略该章节**:否决。无约束的缺失在「经审计的零影响」和「忘记写文档」之间有歧义。省略仅限于在验证器中以理由命名的模型无关通用 package;模型相邻的零影响 package 保留一句显式说明。 -- **对审计过的零影响或简单间接 package 也要求完整结构化形式**:否决。围绕一个事实重复标签没有意义。受门禁约束的单句保留了显式覆盖而无需繁文缛节。 +- **要求经审计的零效应包或简单间接包使用完整结构化格式**:否决。它会围绕一个事实重复标签。受门禁约束的句子加 cache 字段既保留显式覆盖,又没有多余仪式。 - **只有约定而无门禁**:否决。仓库级契约必须覆盖未来的每个 package;评审者的记忆无法可靠地检测到遗漏的 README 章节。 ## 后果 -评审者可以从任何面向模型或模型相邻的 package 出发,直接看到它对会话模型、子模型和辅助调用的贡献,无需重建完整的插件图。token 预算工作可以区分每次请求的重复开销与数据依赖的历史,agent 作用域的变更有了显式的文档检查点。package 作者在模型可见行为变更时维护一个或多个紧凑的上下文表面块或一句分类说明;经审计的通用 package 不承载无关的模型样板文字。结构化字段不承诺提供方精确的 token 数;测量仍然是模型和负载特定的,而文档化的增长与可见性契约保持稳定。 +评审者可以从任何面向模型或邻近模型的包开始,看到它对对话模型、子模型和辅助调用的贡献,无需重建完整插件图。token 预算工作可以区分重复请求开销和依赖数据的历史,而 cache 敏感工作可以识别仅追加路径,以及最早由包引起的前缀变更。agent 范围变更有明确的文档检查点。每当模型可见行为发生变化时,包作者都要维护一个或多个紧凑的上下文表面块,或一种已分类的短格式;经审计的通用包不携带无关的模型样板。结构化字段不承诺 provider 精确的 token 数或 cache 命中;测量仍取决于模型、provider 和工作负载,而所记录的增长、可见性和前缀稳定性契约保持稳定。 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index d94f825076..aeda386ff4 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-19-drop-mutable-session-summary.md: 0f005a78045869d62eb141c9bce8af037671b687 -2026-06-19-drop-mutable-session-summary.zh.md: a33b057c2177258e0e3e2c0c3f78176835acb070 +2026-06-19-drop-mutable-session-summary.md: 80fe043e365352b17d2a5b3efa1ab8d396d311c4 +2026-06-19-drop-mutable-session-summary.zh.md: ec98c19093369400b9b42a5c1a0590b5c6913ab9 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index a33b057c21..ec98c19093 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除可变的会话摘要 +# Agent Note: 移除可变的会话摘要 Status: implemented @@ -12,7 +12,7 @@ Status: implemented - `SessionPersistence.update()` **零个生产调用方**(所有 `.update(` 匹配都是 `createHash().update()` 或测试代码)。 - `firstPrompt` 在生产代码中**从未被读取**。 -- `title` *确实*在 ACP 桥接层被读取过,但读的是工具调用的 **presenter**(`present.title`),从未读取存储的会话元数据。 +- `title` *确实*在 ACP(Agent Client Protocol)桥接层被读取过,但读的是工具调用的 **presenter**(`present.title`),从未读取存储的会话元数据。 - `updatedAt` **没有消费方**:`list()` 唯一的生产调用方读取的是 `meta.cwd`(`SessionHeader` 字段),用于在 `session/load` 时校验工作区;恢复会话读取的是 `createdAt`/`cwd`/`parentSession`——全是 header 字段。 - 决定性的一点:活跃的 `Session.header` 类型本来就是 `SessionHeader` 而非 `SessionMeta`——摘要从未存在于活跃会话对象上;它只存在于持久化层,除了自身的契约测试外无人写入、无人读取。 @@ -22,7 +22,7 @@ Status: implemented 摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一*不可*派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 -将此记录为决策,原因有三:**持久性**(它收窄了一个公开服务契约和跨两个后端的磁盘格式)、**争议性**(摘要是有意的前瞻性设计,而非意外产物)、**意外性**(未来读者看到 `SessionHeader` 而原始 RFC 描述的是 `SessionMeta`,否则会疑惑摘要为何消失)。它还为 [shared persistence write coordinator](../architecture/2026-06-18-shared-persistence-write-coordinator.md) 扫清了障碍:没有可变摘要后,协调器的钩子接口无需 `updateSummary` 钩子,JSONL 伴随文件与 SQLite 列之间的持久性分歧也随之消失,两个后端的写入路径得以统一。 +这被记录为一项决策,因为它具有**持久性**(它同时收窄两个后端的公共服务契约和磁盘格式)、**争议性**(summary 是有意为未来设计的结果,而非意外),也具有**意外性**(未来读者在原 Agent Note(agent 决策记录)描述 `SessionMeta` 的位置发现 `SessionHeader`,否则会追问 summary 为何消失)。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md)扫清障碍:不再有可变 summary 后,协调器的 hook 接口不需要 `updateSummary` hook,JSONL sidecar 与 SQLite 列之间的持久性分歧也随之消失,使两个后端的写入路径趋于一致。 ## 无需迁移 @@ -32,4 +32,4 @@ Status: implemented 未来的会话选择器现在必须从日志派生预览/排序信息(或重新引入一个类型化字段),而不能直接读取现成的摘要行。这是正确的代价:为一个尚不存在的功能维护缓存,是每个后端都要付出维护成本、每个契约测试都要付出断言成本的死重。这一原则——**通过的测试固定的是当前行为,不一定是正确行为;行为可能是过去妥协的产物**——现已作为独立约定记录在[根 AGENTS.md](../../../../AGENTS.md) 中,本次变更即为其实例。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml index 784b07f47b..1240ee061a 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-collapse-trace-only-session-events.md: c446f43d887088fcf562305fcc2dad37465fb124 -2026-06-20-collapse-trace-only-session-events.zh.md: eda7f738abbaced393f2588867f9dcd8e86e2386 +2026-06-20-collapse-trace-only-session-events.md: fce5c48ef6fcb1abc6e2fbb95dc7e83d22956660 +2026-06-20-collapse-trace-only-session-events.zh.md: 4302646b78ea0b22b479c7068f0f6259bf79edec diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md index eda7f738ab..4302646b78 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -1,4 +1,4 @@ -# RFC: 将仅用于追踪的会话事实折叠进承载性事件 +# Agent Note: 将仅用于追踪的会话事实折叠进承载性事件 Status: implemented @@ -35,7 +35,7 @@ Status: implemented ## 实现说明 -按提案交付,有一处范围细化(遵循 AGENTS.md「RFC 是提案,不是金科玉律」): +按提案落地,但有一处范围细化(遵循 AGENTS.md 所述“Agent Note(agent 决策记录)是提案,而非绝对真理”): - **空内容 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片无处安放)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),以前会发出独立的 `usage`。现在它记录一个空内容的 `assistant/message { content: [], usage }`。为防止这向 provider transcript 注入一个无内容的虚假 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。回归测试断言 usage 仍被表示,且派生历史未被破坏。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml index 7534bcc2da..012f47ae00 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-unconsumed-llm-adapter-change-event.md: 657e5f08c02e1e03eedeccf8a30b8bc851201615 -2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: fbc2248c8357dbff3f5f5008647c14c23a66d5b0 +2026-06-20-drop-unconsumed-llm-adapter-change-event.md: a3c7c089d7dfa1a4cd6a891c416bf270dc7eff3d +2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: 8839a4c263462f2bae75d8698b20007e8348d903 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md index fbc2248c83..8839a4c263 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除未被消费的 `llm/adapter-change` 事件 +# Agent Note: 移除未被消费的 `llm/adapter-change` 事件 Status: implemented @@ -8,25 +8,25 @@ Status: implemented `LlmService.registerAdapter()` 在注册和 dispose(资源释放)时发出 `llm/adapter-change` 事件([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))。在 `packages/*/src` 和 `examples/*/src` 中搜索 `llm/adapter-change`,只能找到声明、emit 站点、文档和测试;没有任何生产环境的监听器订阅它。 -这与 `tools/change` 和 `system-prompt/change` 不同。后两个事件目前同样未被消费,但它们是合理的注册表变更信号,未来可能服务于实时工具/提示词 UI。LLM(大语言模型)适配器注册更接近启动时的实现细节:适配器不是用户可见的面板,真正的模型调用拦截 seam 是 `llm/stream`。保留一个没有监听器的 adapter-change 事件,是在更小规模上重复 [drop-the-dead-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 的模式。 +这与 `tools/change` 和 `system-prompt/change` 不同。如今这两个事件同样没有消费者,但它们有望成为未来实时工具/prompt UI 的注册表变更信号。LLM(大语言模型)adapter 注册更像是启动时的实现细节:adapter 不是用户可见的选项面板,真正的模型调用拦截接缝是 `llm/stream`。保留一个没有监听器的 adapter 变更事件,只是在更小范围内重复[删除无用 summary](2026-06-19-drop-mutable-session-summary.md) 的模式。 这个事件并非零成本。`registerAdapter()` 在发出 `llm/adapter-change` 之前先 yield 回滚 disposer,这样抛出异常的监听器会回退变更而非泄漏适配器条目;包内还有针对该监听器抛出路径的测试。这种防御性排序保护的是一个只有测试才能触发的失败模式。 ## 决策 -仅移除 `llm/adapter-change`:`dsh-llm` 的 `interface Events` 中的声明、`ctx.emit('llm/adapter-change')` 调用,以及 `LlmService.registerAdapter` JSDoc 中的 "Emits `llm/adapter-change` on registration and disposal" 语句。`registerAdapter()` 的 effect generator 保留变更与回滚 disposer 以支持 HMR(热模块替换)/dispose,但去掉了仅为已移除事件而存在的监听器抛出回滚排序。适配器 disposer 测试断言返回的 disposer 能移除适配器,而不再订阅该事件;监听器抛出回滚测试随其主题一同移除。[docs/architecture.md](../../../architecture.md) 和 [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) 中的事件分类体系在同一个变更中更新。 +只移除 `llm/adapter-change`:包括 `dsh-llm` 的 `interface Events` 中的声明、`ctx.emit('llm/adapter-change')` 调用,以及 `LlmService.registerAdapter` JSDoc 中“在注册和释放时发出 `llm/adapter-change`”的句子。`registerAdapter()` 的效应生成器为 HMR(热模块替换)/释放保留变更与回滚 disposer,但移除仅因该事件而存在的监听器抛错回滚顺序。adapter disposer 测试断言返回的 disposer 会移除 adapter,不再订阅事件;监听器抛错回滚测试则随其测试对象一起消失。[docs/architecture.md](../../../../docs/architecture.md) 和 [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) 中的事件分类也在同一变更中更新。 ## 曾考虑的替代方案 ### 为什么不移除所有注册表变更事件? -一个注册表主动广播变更的微内核是一种自洽的约定。`tools/change` 和 `system-prompt/change` 在 UI 能实时刷新可用工具或提示词段落时可能变得有用。本 RFC 保留该约定中有合理的面向用户消费方的部分,仅裁掉当前和可预见未来消费方都不明确的 adapter-change 事件。 +由注册表通告变更的微内核是一种一致的约定。当 UI 能够实时刷新可用工具或 prompt 章节时,`tools/change` 和 `system-prompt/change` 可能会有用。本 Agent Note(agent 决策记录)在存在合理用户侧消费者的位置保留该约定,只删除当前及可能的未来消费者都不明确的 adapter 变更事件。 如果将来需要 LLM 适配器浏览器或动态模型选择器用到此信号,届时再连同消费方一起重新引入,并提供比「something changed」更清晰的 payload。 ## 验证 -`llm/adapter-change` 及其 emit 已移除,重新生成的 cordis catalog 是最新的;HMR 安全性保持(dispose 一个贡献 fiber 会移除对应适配器);`tools/change` 和 `system-prompt/change` 仍有文档和测试;没有任何生产路径的可观察行为发生变化——ACP(Agent Client Protocol)快照 golden 和 echo-agent 冒烟测试逐字节未变。 +`llm/adapter-change` 及其 emit 已消失,重新生成的 Cordis 目录保持新鲜;HMR 安全性仍成立(释放贡献该 adapter 的 fiber 会移除它);`tools/change` 和 `system-prompt/change` 仍有文档与测试;ACP(Agent Client Protocol)快照和无密钥 Headless Loader 冒烟则固定了未变的生产路径。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml index f9986e5608..0fdd9342a5 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: ead3a8c094b0fc0b4bd01671bd4a1165dd555ea2 -2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 16704b511151c329622e58c3b5f6b2cff330f366 +2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: b6b596e822b4bd6fd1bd891c336c622ad675ad45 +2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 5a4e378958f5fb594345305804a3b90e493f0560 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md index 16704b5111..5a4e378958 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除未被消费的 LLM 组装便捷接口 +# Agent Note: 移除未被消费的 LLM 组装便捷接口 Status: implemented @@ -14,7 +14,7 @@ Status: implemented LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体循环),它只使用 `stream()`:将原始分片送入自己的 `BlockAssembler`,以便在并行组装的同时记录分片,保证回放保真度([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts),`ctx.llm.stream(req)` 步骤)。在 `packages/*/src` 和 `examples/*/src` 中 grep `streamBlocks` 与 `ctx.llm.generate`,找不到任何生产调用方。仅有的引用来自服务方法定义、文档和测试;适配器测试用 `generate()` 作为便捷驱动,但它们完全可以通过同一个 assembler 辅助函数手动消费 `stream()`,无需为此保留一个公开的生产 API。 -这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:拥有经过测试的契约的组装视图 API,消费方却只有测试而非生产代码。它们是为「不关心 token 级增量」的消费方预设的,但唯一的真实消费方恰恰需要增量,以便持久化高保真回放数据。 +这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:带有受测契约的组装视图 API,由测试而非生产代码消费。它们是为不关心 token 级增量的消费者推测性构建的,但唯一的真实消费者恰恰关心增量,以便持久化高保真重放数据。 `streamBlocks()` 拖带了 `BlockAssembler` 的一块专用逻辑:`flushReady()` 与 `flushRemaining()`([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts))以及 `flushed` 游标字段,仅为支持按序增量产出而存在。`generate()` 拖带了 `GenerateResult`、`BlockAssembler.result()` 以及 `llm/generate` waterfall——在同一底层流之上的第二个拦截面。agent loop 对 assembler 的使用仅限于 `push()` / `message()` / `usage` / `finish`,不涉及流式 flush 或一次性服务组装。 @@ -28,7 +28,7 @@ LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体 ## 验证 -`streamBlocks`、`generate`、`llm/generate` 及其独占的 assembler 辅助方法已移除,无新增死导出;两个真实适配器通过 `stream()` 和共享 assembler 得到验证;agent loop 行为不变(ACP 快照 golden 文件无变化);README、架构文档与模块文档中不再提及已移除的接口。 +`streamBlocks`、`generate`、`llm/generate` 及仅供它们使用的 assembler 辅助函数均已移除,且未产生新的无用导出;两个真实 adapter 都通过 `stream()` 和共享 assembler 接受测试;循环行为保持一致(ACP(Agent Client Protocol)快照预期输出未变);README、架构文档和模块文档也不再提及已删除表面。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml index b777318d66..a2d0e004e3 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-prune-dead-seam-methods.md: fc656f4fc46837a75fa6c34c2de18cffde954ef3 -2026-06-20-prune-dead-seam-methods.zh.md: 2b8b2afea97aa28d32d55a9def217a593e9dbc7d +2026-06-20-prune-dead-seam-methods.md: bb91194ed0483ca4c43acdde8370e7152ac876ea +2026-06-20-prune-dead-seam-methods.zh.md: d64e9b5b6d40fbb0f52705a3385123b072360607 diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 782ffe891e..bb91194ed0 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-prune-dead-seam-methods.zh.md) + > **Implementation note:** Only `SessionPersistence.has()` and `.delete()` were removed. `BashExecutor.get()` and `.list()` remain because removing their one-line lookup surface required substantially more completion-tracking machinery in consumers. Their id branding is covered by the [branded-ids Agent Note](../architecture/2026-06-20-branded-ids.md). ## Problem diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md index 2b8b2afea9..d64e9b5b6d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md @@ -1,27 +1,27 @@ -# RFC: 从 persistence seam 中移除无用方法 +# Agent Note: 从 persistence seam 中移除无用方法 Status: implemented [English](2026-06-20-prune-dead-seam-methods.md) | 中文 -> **实现说明:** 最终只移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 保留,因为移除它们的单行查询接口需要在消费方引入大量额外的完成状态追踪机制。它们的 id 品牌化由 [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) 覆盖。 +> **实现说明:** 仅移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 仍然保留,因为删除它们的单行查找表面会要求消费者增加显著更多的完成跟踪机制。其 id 品牌化由[品牌化 id Agent Note(agent 决策记录)](../architecture/2026-06-20-branded-ids.md)负责。 ## 问题 -一个能力 seam([接口/实现/消费方](../../implemented/architecture/2026-06-13-capability-seams.md))承载着没有任何消费方调用的抽象方法。seam 的存在是为了让实现与消费方独立演进,但一个没有消费方编程依赖的方法不是 seam,而是每个实现仍须实现和测试的投机性接口面。 +能力接缝([接口 / 实现 / 消费者](../architecture/2026-06-13-capability-seams.md))承载了没有消费者调用的抽象方法。接缝的存在是为了让实现和消费者独立演进——但没有消费者以之编程的方法不是接缝,而是每个实现仍必须实现和测试的推测性表面。 ### `SessionPersistence.has()` 与 `.delete()` 该抽象服务在 create/append 之外声明了更多操作:`load`、`list`、`has`、`delete`。`ctx.sessionPersistence` 的生产消费方只用了两个:agent loop(智能体循环)的恢复路径调用 `load()`([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)),ACP(Agent Client Protocol)桥接层为 `session/list` 调用 `list()`([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts))。在 `packages/*/src` 和 `examples/` 中 grep 所有 `sessionPersistence.*` / `persistence.*` 的使用,找不到对该服务的 `has(` 或 `delete(` 调用。`packages/ui/acp/src/index.ts` 中的 `.has(`/`.delete(` 调用作用于内存中的 `SessionStore` 和一个本地的 loading id `Set`,而非 persistence。`has`/`delete` 的唯一调用者是契约测试套件和各后端的 spec。 -`has()` 不仅是未使用——它还是共享协调器中最复杂的分支:一个 tracked-vs-untracked 双探测(`loadLive(id, cwd)` 用于活跃追踪的会话,`loadStored(id)` 用于未追踪的会话),附带多行注释说明理由。`delete()` 则拖带了 `deleteStored` 后端钩子,每个后端都必须实现它。这与 [drop-mutable-session-summary](../../implemented/simplification/2026-06-19-drop-mutable-session-summary.md) 是同一模式:契约测试覆盖了两者,但没有任何发布代码会问「这个会话是否已持久化?」或删除一个会话。 +`has()` 不仅没有被使用——它还是共享协调器中最复杂的分支:带有多行理由说明的“已跟踪/未跟踪”双重探测(对实时跟踪的 session 使用 `loadLive(id, cwd)`,对未跟踪 session 使用 `loadStored(id)`)。`delete()` 则拖入每个后端都必须实现的 `deleteStored` 后端 hook。这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:契约测试覆盖了两者,但已发布代码从不会询问“这个 session 是否已持久化?”或删除某个 session。 ## 决策 没有消费方使用的方法被移除——从抽象 seam、实现,以及仅为覆盖它们而存在的契约/spec 测试套件中移除: -- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` 钩子(jsonl 和 sqlite 各自实现 `deleteStored` 仅为满足该钩子——这些实现也一并移除)。后端属于[双后端](../../implemented/architecture/2026-06-14-session-persistence.md)设计,本身不在本次范围内;移除它们为无消费方实现的钩子是移除钩子的一部分,而非后端重新设计。 -- 所有文档和源码注释中的引用都已更新为存留的四方法、仅含 `list()` 的契约——不仅是字面的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和「六个公开方法」之类的计数——涉及 seam 和后端 README、[docs/architecture.md](../../../architecture.md)、[session-persistence](../../implemented/architecture/2026-06-14-session-persistence.md) 和 [write-coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) RFC,以及协调器/后端的 JSDoc。 +- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` hook 均消失(jsonl 和 sqlite 都只是为了满足该 hook 才实现 `deleteStored`,这些实现也一并移除)。后端属于[双后端](../architecture/2026-06-14-session-persistence.md)设计,其他方面不在范围内;删除它们为没有消费者的 hook 所做的实现,是删除 hook 的一部分,而非重新设计后端。 +- 所有文档和源码注释引用都已更新为保留下来的四方法、仅含 `list()` 的契约——不仅包括字面上的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和“六个公共方法”的计数——涉及接缝和后端 README、[docs/architecture.md](../../../../docs/architecture.md)、[session persistence](../architecture/2026-06-14-session-persistence.md) 与[写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md) Agent Note,以及协调器/后端 JSDoc。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index eec7c0f05d..925bfbb43e 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-public-agent-stop-surface.md: d34f21be66892e261f1435070aaf9b478c8dc7cd -2026-06-20-public-agent-stop-surface.zh.md: a510d39044048637c2462fd1d97eb3474931761a +2026-06-20-public-agent-stop-surface.md: 81a21de30bfbc25688069efbffb21647889b1bdb +2026-06-20-public-agent-stop-surface.zh.md: 4ad4b6b0beb01bc55f10c046023c7851558ecb20 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index a510d39044..4ad4b6b0be 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 保留单一公开停止原语 +# Agent Note: 保留单一公开停止原语 Status: implemented @@ -8,19 +8,19 @@ Status: implemented ## 问题 -公开的 `Agent` 句柄暴露了两种重叠的方式来停止进行中的工作:`abort(reason?)` 和 `cancel(reason?)`。`abort()` 仅终止当前步骤,不影响队列中的工作;`cancel()` 清除队列中的工作和 steering(中途引导)工作、中止正在运行的步骤,并处理步骤前竞态。在生产环境中,ACP(Agent Client Protocol)使用 `cancel()` 实现 `session/cancel`,而生命周期所有者通过 `AgentHandle.dispose()` 销毁 agent(智能体)。没有生产调用方需要裸 `abort()`。 +公共 `Agent` handle 暴露了两种相互重叠的在途工作停止方式:仅针对 step 的 `abort()` 和感知队列的 `cancel()`。前者保留已排队输入,后者则清除已排队和 steering(中途引导)工作,并中止活动 turn。在生产中,ACP(Agent Client Protocol)对 `session/cancel` 使用 `cancel()`,生命周期拥有者则通过 `AgentHandle.dispose()` 拆除 agent(智能体)。没有生产调用方需要一个裸的、仅针对 step 的 abort。 -`abort()`/`cancel()` 的区别是真实存在的:`abort()` 保留队列中的提示词和 steering,而 `cancel()` 丢弃它们。但没有任何已上线的代码调用过公开的 `abort()` 动词。循环自身的停止路径(`cancel()` 和 disposal)直接中止当前 `AbortController`,而不经由 `Agent.abort()` 路由。大多数调用 `abort()` 的测试中断的是空队列,可以改用 `cancel(reason)`;那个刻意依赖队列保留的 steering 重投递测试则直接驱动进行中的 `AbortController`,因为 `cancel()` 会丢弃它试图证明在步骤中止后仍存活的已排队 steering。无参 `abort()` 的默认原因(`'aborted'`)随该动词一起删除,而非被意外保留;`cancel()` 保留自己的 `'cancelled'` 默认值。 +行为差异确实存在,但已发布代码不需要较窄的操作。AgentLoop 改为为整个 turn 拥有一个私有取消 holder。`cancel(cause?)` 携带类型化的 `user` 或 `parent` 原因,默认为 `user`,并丢弃待处理输入;释放仍是单独的生命周期中断。完整的归属与传播契约位于[显式 turn 取消 Agent Note(agent 决策记录)](../architecture/2026-07-16-explicit-turn-cancellation.md)。 多余的公开接口使得循环不得不承载一个本质上属于内部拆卸的公开动词:`abort()` 必须被文档描述为有别于队列感知的取消,尽管 UI 取消几乎总是需要更广泛的操作。 ## 决策 -`cancel()` 是 `Agent` 上唯一的公开*停止*原语。生命周期所有者使用 `AgentHandle.dispose()` 停止并注销 agent;非所有者使用 `cancel()` 放弃当前和队列中的工作。实现内部保留一个私有的 abort controller,但它不属于面向插件的 `Agent` 契约。 +`cancel()` 是 `Agent` 上唯一的公共*停止*原语。生命周期拥有者使用 `AgentHandle.dispose()` 停止并注销 agent;非拥有者使用 `cancel()` 放弃当前和已排队工作。实现保留一个私有 turn 取消 holder,但它不属于面向插件的 `Agent` 契约。 `whenIdle()` **保留**为公开的静默观测原语(agent 从 `running` 状态稳定后 resolve,已处于 idle 时立即 resolve,dispose 后等待循环退出)。它不是停止动词;它是非所有者在不 dispose agent 的前提下观测停止*完成*的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 销毁它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 -公开的 `abort()` 被删除,连同将其作为独立 API 测试的用例以及将步骤级中止描述为嵌入特性的文档。空队列中止测试迁移到 `cancel(reason)`,仍然验证取消行为;以循环内部 `AbortController` 为测试对象的用例通过包内类型转换直接驱动该 controller 的私有字段;仅固定已移除的无参 `abort()` 默认值的测试随方法一起删除。disposer 仍为异步,仍等待循环停止。 +公共 `abort()` 已不存在,disposer 仍为异步并等待循环停止。测试通过公共类型化原因和显式 signal 接缝验证取消,而不会伸入 holder 内部。 ## 曾考虑的替代方案 @@ -36,4 +36,4 @@ Status: implemented ## 相关 -本 RFC 仅移除冗余的停止动词。中途 steering 仍是有意保留的消息路径;静默观测仍通过 `whenIdle()` 提供。最终的公开接口为 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 和 identity。 +本 Agent Note 只移除冗余的停止动词。turn 中途 steering 仍是一条有意保留的消息路径;静止观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 和 identity。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 16911f7c70..64c80eedcc 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-remove-agent-boundary-mirror-events.md: 0ea8512ca6b66b2e8ab8cf2746bdc65d79caa9d4 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: 30632bf223cb41c18f62c18a544ff42f70af1136 +2026-06-20-remove-agent-boundary-mirror-events.md: 8c5bb74f2347fe0269cbb9c6504137de761ab919 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: feed8239b6a07c2e27866f72954cfb95830541f4 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index 910eb46e92..8c5bb74f23 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-remove-agent-boundary-mirror-events.zh.md) + <!-- Shipped in AMENDED, narrowed form: the four turn/step BOUNDARY mirrors are removed; `agent/steering` and `agent/stream-chunk` were RETAINED here (they are not durable-boundary mirrors — see "Scope: what is and isn't removed"). diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index 30632bf223..feed8239b6 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -1,12 +1,21 @@ -# RFC: 停止将持久化边界镜像为 agent 事件 +# Agent Note: 停止将持久化边界镜像为 agent 事件 Status: implemented [English](2026-06-20-remove-agent-boundary-mirror-events.md) | 中文 +<!-- 以修订、收窄后的形式落地: + 移除了四个 turn/step 边界镜像;此处保留了 `agent/steering` 和 + `agent/stream-chunk`(它们不是持久边界镜像——参见 + “范围:移除什么、不移除什么”)。原始提案将 `agent/steering` 与其他项一并 + 移除;把它排除在外,使本 Agent Note 的范围保持在边界上。后来每个保留事件 + 都由各自的决策移除——参见 + [停止将 token 流镜像为 agent 事件](2026-07-02-remove-stream-chunk-mirror.md) + 和[移除 `agent/steering` 镜像 emit](2026-07-04-remove-agent-steering-mirror.md)。 --> + ## 问题 -agent loop(智能体循环)通过可回放的 `SessionEvent` 日志和实时 `agent/*` 镜像两条路径暴露持久化的轮次与步骤边界。消费方不得不在同一事实的两个来源之间做选择,并协调二者的时序。ACP(Agent Client Protocol)和持久化层已经使用日志;stdio UI 是唯一仍在消费镜像的组件,而它已经从 `session/event` 渲染工具调用和工具结果。 +循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费者在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择 session log,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染 turn 边界的生产消费者;它已经从 `session/event` 渲染工具调用和结果。 这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 @@ -14,19 +23,25 @@ agent loop(智能体循环)通过可回放的 `SessionEvent` 日志和实时 将 `session/event` 作为唯一的实时边界/transcript(文本记录)流。需要渲染轮次、工具调用、工具结果、助手消息和持久化边界的消费方统一订阅 `session/event`,从持久化层使用的同一套事件词汇中派生 UI。 -移除 `agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。边界消费方改为订阅 `session/event`。如果 UI 需要 agent 标签,则通过 `agent/created` 和 `agent/disposed` 维护一份 session 到 agent 的映射,因为持久化的 `turn/start` 携带轮次编号但不携带 agent id。 +四个持久边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——已从 agent(智能体)事件分类中移除。希望在边界处取得 agent handle 的 UI 会保留来自 `agent/created`/`agent/disposed` 的实时目标对象,并直接比较其 session;`dsh-ui-stdio` 据此为应用拥有的 agent 标记 `[main turn N]` 头部,其他 session 则渲染其持久 id。规范记录仍是事件溯源 session log。 -步骤镜像已无消费方,由 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 先行移除。该决策保留了轮次镜像供 stdio UI 使用;本 RFC 在将测试 REPL 迁移到 `session/event` 加 id 映射之后,将轮次镜像也一并移除。 +step 镜像(完全没有消费者)最先在[事件域语义 Agent Note(agent 决策记录)](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在 turn 边界取得 `Agent` handle 为由,保留了 turn 镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此“ui-stdio 需要它”并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 ## 范围:移除什么、不移除什么 -本决策仅涉及持久化的轮次与步骤边界。`agent/steering` 镜像的是一条控制记录,`agent/stream-chunk` 镜像的是 token 流,因此各自单独处理:[steering](2026-07-04-remove-agent-steering-mirror.md) 与 [stream chunks](2026-07-02-remove-stream-chunk-mirror.md)。`agent/created`、`agent/disposed`、`agent/status`、`agent/error` 和 `agent/queued` 仍作为实时生命周期或控制事件保留,而非 transcript 镜像;排队中的输入可能在任何持久化事件产生之前就被取消。 +已移除(持久边界镜像——每项都以 session log 为权威):`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`。 + +保留——不是持久边界镜像,因此不在本决策范围内: + +- `agent/steering`——不是边界,因此不在本决策范围内(原始提案将其一并移除;在此会造成范围蔓延)。它镜像持久的 `steering/message` 控制记录,而非边界,后来由自己的后续决策移除:[移除 `agent/steering` 镜像 emit](2026-07-04-remove-agent-steering-mirror.md)。 +- `agent/stream-chunk`——实时 token 流。不在本决策范围内(它镜像持久的 `assistant/chunk`,而非边界),后来由自己的后续决策移除:[停止将 token 流镜像为 agent 事件](2026-07-02-remove-stream-chunk-mirror.md)。 +- `agent/created`、`agent/disposed`、`agent/status`、`agent/error`、`agent/queued`——不属于 transcript 数据的生命周期/控制事件。尤其是 `agent/queued`,它是在任何持久事件存在之前触发的 inbox 确认(取消的排队工作可能永远不会进入日志),所以有意只保留为实时事件。 ## 曾考虑的替代方案 -- **在同一个变更中一并移除 `agent/steering`**:否决,因为它是控制记录的镜像而非边界镜像。 -- **为 stdio UI 保留轮次镜像**:否决,因为 UI 可以渲染 `session/event` 并通过 id 映射恢复 agent 标签。 +- **将 `agent/steering` 一并移除**——原始提案的形状;作为范围蔓延被排除:它镜像持久的 `steering/message` 控制记录,而非边界,后来由[自己的决策](2026-07-04-remove-agent-steering-mirror.md)移除(`agent/stream-chunk` 也由 [stream chunk 镜像 Agent Note](2026-07-02-remove-stream-chunk-mirror.md) 移除)。 +- **为 stdio UI 保留 turn 镜像**——[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的原始立场;在此否决,因为 `dsh-ui-stdio` 是可随时丢弃的测试 REPL,而非承载关键约束的消费者,并且它改为根据 `session/event` 加自己的实时目标对象渲染边界。 ## 后果 -插件不再能从便捷的 `Agent` 优先事件中观察轮次/步骤边界,必须订阅 `session/event` 或自行维护 session 到 agent 的关联。这是可接受的取舍:边界消费方不应依赖一条可能与持久化日志产生漂移的第二事件源。 +插件不能再从便捷的 `Agent` 优先事件观察 turn/step 边界。它需要订阅 `session/event`;如果需要实时对象,则通过 `ctx.agents` 解析共享 id,或保留自己已经拥有的对象。这是可以接受的取舍:边界消费者不应依赖可能与持久日志发生漂移的第二条事件 feed。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml index 1c1c0eb757..f52f1e62c4 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-unify-agent-and-session-id.md: 9cec898a2df9418b3533c776779c88fc50bc7dcb -2026-06-20-unify-agent-and-session-id.zh.md: 39e78db0b886d1e0b13afe2337697a184af478ed +2026-06-20-unify-agent-and-session-id.md: c55152f4f13fe0acb530503e84f465799007cff7 +2026-06-20-unify-agent-and-session-id.zh.md: 46e786a8f65b33e071fe689922ab396be90a184a diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.md index a8a2c375b5..c55152f4f1 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-unify-agent-and-session-id.zh.md) + ## Problem A live agent/session pair needs one identity for registry routing, event sourcing, and persistence. Giving the factory independent `agentId` and `sessionId` inputs would permit pairings no production path can use, while forcing every consumer to choose or translate between two names for the same lifecycle. diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md index 39e78db0b8..46e786a8f6 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -1,42 +1,40 @@ -# RFC: 统一 agent id 与 session id +# Agent Note: 统一 agent id 与 session id -Status: proposed +Status: implemented [English](2026-06-20-unify-agent-and-session-id.md) | 中文 ## 问题 -agent 工厂为每个活跃的 agent/session 对维护两个 id:`agentId`(`AgentRegistry` 的路由句柄)和 `sessionId`(事件溯源与持久化日志的标识)。`CreateAgentOptions` 接收两者;`ResumeAgentOptions` 接收 `agentId` 加 `resumeSessionId`;进程内 subagent 各自铸造两个独立的 UUID,尽管血缘关系另行记录。 +一个实时 agent(智能体)/session 对需要使用同一 identity 完成注册表路由、事件溯源和持久化。让 factory 接受相互独立的 `agentId` 和 `sessionId` 输入,会允许任何生产路径都无法使用的配对,同时迫使每个消费者为同一生命周期在两个名称之间选择或转换。 -ACP(Agent Client Protocol)已经对这两个标识使用同一个值。它们在配置创建的 agent(智能体)、恢复的会话和进程内子 agent 中才出现分歧,但没有任何生产路径会把一个活跃 agent 重新关联到多个会话,或让一个会话经过多个 agent id。Stdio 保留 `labelBySession` 仅仅是为了从会话事件中恢复 agent 标签,而钩子同时暴露两个值让使用者自行调和。 +ACP(Agent Client Protocol)对两种 identity 使用相同值。Stdio 和 hook 也在 session 事件流上工作,并且直接需要对应的实时 agent;没有生产路径会把一个实时 agent 对象重新附着到多个 session,或通过多个 agent id 驱动一个 session。 -[agent-scope 运行时](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)没有与标识相关的保留状态:创建和恢复使用同一个 `AgentCreationTransaction`,两个注册表条目都使用相同的 final-entry 碰撞规则。分离的 id 并不会使活跃性、回滚或静默机制产生重复。统一后删除一个调用方提供的 id、每个进程内子 agent 的一个 UUID 以及剩余的转换路径,而不改变事务生命周期;同时使活跃 agent 注册表强制执行后台任务所有权所使用的会话标识。 +[agent 范围运行时](../architecture/2026-07-12-agent-scope-runtime-design.md)使用同一个 `AgentCreationTransaction` 执行创建和恢复,agent/session 条目共享相同的最终条目冲突规则。第二个 identity 并不代表单独的存活性、回滚或静止状态;它只会围绕同一事务增加 API 与转换状态。 -`Session` 另外同时暴露 `Session.id` 和 `Session.header.id`,尽管构造时要求二者必须一致。持久化边界必须校验这个重复值,消费方必须在同一事实的两个归属位置之间做选择。 +Session identity 同样只有一个归属,即 `Session.header.id`;`Session.id` 是派生访问器,而非需要重复验证的独立状态。 -## 提案 +## 决策 -对 agent 注册表条目和 `session.header.id` 使用同一个 id。`CreateAgentOptions` 为两个最终条目接收一个标识;恢复操作以被恢复的 session id 注册 agent;subagent 创建铸造一个合并后的 id;`Session` 只保留一个标识归属位置。保留当前的事务、final-entry 碰撞检查、exact-entry 摘除、回滚与静默机制;仅移除唯一职责是在两个 id 之间做转换的 map 和字段。 +agent 的注册表 id 等于其 session id。`CreateAgentOptions` 接受一个 `sessionId`,同时用于两个最终注册表条目;恢复时以 `resumeSessionId` 注册 agent;进程内 subagent 创建使用子 session id;`Session.id` 则派生自 `header.id`。远程 ACP 运行没有本地 agent/session 对:它保留一个由父项铸造的生命周期 id,而子服务器线协议内的 session id 仅用于 ACP 调用。现有创建事务、最终条目冲突检查和精确条目分离语义保持不变;唯一职责是在本地 id 之间转换的 map 与字段已经消失。 -配置驱动的路径必须先确定其恢复还是创建的策略。当前它使用一个稳定的 agent 标签加一个带 UUID 后缀的新 session id,以避免在下次运行时与已有的持久化日志碰撞。统一后,它必须明确选择:恢复一个固定 id、铸造一个新的合并 id,还是将该策略暴露出来;实现不得默默做出选择。 +配置驱动路径保留 `agents[].id` 作为稳定配置标签,而非实时路由 identity。普通的全新启动会铸造组合 id `${label}-session-${randomUUID()}`,使持久重启不会冲突。耦合应用可以预先铸造并传入精确的 `sessionId`:首次使用时创建它,而当持久化服务已经存在时,AgentLoop 重新挂载会在同一 identity 下恢复已物化历史。`resumeSessionId` 则要求已有的持久化 identity。两个精确 id 输入互斥。Stdio 使用“恢复或创建”形式,使配置创建的 agent 和 UI 在循环重载之间共享一个不透明 identity,而不是根据前缀猜测。日志可以使用稳定标签,而所有实时与持久查找都使用同一个 `SessionId`。 -`agent/created` 和 `agent/disposed` 不在本提案范围内。它们是发布生命周期事件而非标识别名;移除它们需要单独的生产方-消费方审计与决策。 +`agent/created` 和 `agent/disposed` 保留。它们是成对的发布生命周期事件,而非 identity 别名;以后若发现没有消费者并要移除,必须先重新搜索,再提出独立提案。 ## 曾考虑的替代方案 -**保留分离的路由标识与日志标识。** 一个稳定的配置 agent 标签配合一个新的对话,是这种区分的真实用途。如果确实需要该显示或路由标识,请否决本提案,转而显式强制 session id 唯一性,而不是把转换隐藏在另一个 map 中。 +**保持路由与日志 identity 分离。** 稳定的配置标签加全新的持久对话确实有用,但不需要两个实时 identity:标签可以继续作为配置/显示元数据,而每次运行的组合 `SessionId` 负责路由和持久化。保留两个 id 会让转换 map 持续存在,允许不可能的配对,却不会增加生命周期功能。 -## 验收标准 +## 验证 -- agent 创建/恢复与 subagent 创建只携带一个标识;`Session` 将其存储在一个位置。 -- 创建事务在不依赖标识相关生命周期状态的前提下,保留 final-entry 碰撞、exact-entry 摘除、回滚与静默保证。 -- ACP、stdio、钩子、bash 所有权、持久化与血缘关系无需进行 agent/session id 转换。 +- Agent 创建/恢复和 subagent 创建只携带一个 identity,`Session` 也只在一个位置存储它。 +- 创建事务继续覆盖最终条目冲突、精确条目分离、回滚和静止状态,无需 identity 特有的生命周期状态。 +- ACP、stdio、hook、bash 归属、持久化和 lineage 直接使用共享 `SessionId`。ACP subagent 后端在父命名空间中铸造其生命周期 id,因为子服务器返回的 session id 仅在服务器本地有效;ACP bridge 根据正向 session map 验证精确的 `Agent` 归属;JSON-RPC 只转发生命周期事件中由服务快照保存的 `local` 标记为 true 的事件,从带范围的事件 carrier 取得委托父项,并且不保留子 identity 或 lineage cache。 - 配置驱动的恢复还是创建策略是显式的,并在持久化重启场景下得到覆盖。 -- `agent/created` 和 `agent/disposed` 仅在单独的生产方-消费方审计之后才变更。 +- 生产监听器搜索确认保留 `agent/created`/`agent/disposed` 及其发布语义。 - 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 -## 风险 +## 后果 -统一后将无法再拥有一个跨多个会话日志的稳定 actor 标识,包括未来可能出现的、在保留 actor 的同时切换会话的 handoff 或 fork 场景。重新引入该设计将需要一个新的显式 actor 标识。统一还使一个持久化的、可能由客户端选定的 session id 成为注册表句柄,并改变每个创建/恢复的调用点与 fixture(测试前置数据)。 - -配置重启策略是阻塞性的设计决策:固定的合并 id 可能与已有日志碰撞,而每次运行生成新 id 则放弃了稳定的配置标签。如果确实需要独立的 actor 标识或稳定标签/新会话的配对,请否决本提案,保留分离的 id 并加上显式的唯一性守卫。 +这排除了潜在的多 session actor 和 session 交接设计,并使由客户端选择、已持久化的 session identity 成为注册表 identity。如果独立路由 identity 成为真实需求,就需要显式的生命周期设计,而不是由调用方提供一对不受约束的值。 diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml index f1fa61c3c2..cc7da6603e 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-26-fsspec-style-fs-seam.md: 7b5e61481eeca9556de58cf3d6f8fe935b2eefb5 -2026-06-26-fsspec-style-fs-seam.zh.md: 72d37c196df99d110ea59c5108dc9de084669c8b +2026-06-26-fsspec-style-fs-seam.md: d496f273e2635624e0ab8e70e06c8729563c5466 +2026-06-26-fsspec-style-fs-seam.zh.md: d6217e768fe4a11a7f6aacf8a17bb2e9e232a44d diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md index 72d37c196d..d6217e768f 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md @@ -1,4 +1,4 @@ -# RFC: 拆分文件系统 seam——提供方文本变更操作与 `dsh-fs-policy` 插件 +# Agent Note: 拆分文件系统 seam——提供方文本变更操作与 `dsh-fs-policy` 插件 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 中引入的文件系统能力目前让一个抽象的 `FileSystem` 服务承担两类不同的职责: +[文件系统能力接缝](../architecture/2026-06-17-filesystem-capability-seam.md)中的文件系统能力目前让一个抽象 `FileSystem` 服务同时负责两项不同工作: 1. **提供方操作**——解析目标、stat/版本元数据、文本读取/流式读取、原子写入,以及受保护的字面编辑。 2. **面向 agent(智能体)的策略**——行窗口、字面编辑语义,以及读后写/编辑的观测状态。 @@ -15,7 +15,7 @@ Status: implemented 这还造成了一个真实的用户体验死胡同:窗口化读取记录 `view: partial`,而 partial 视图无法授权 `edit`。一个模型读取了大文件的第 100-150 行,如果想编辑第 120 行,就必须先获取一次 `full` 读取,而对于超过读取上限的文件这可能做不到。字面编辑实际上只需要新鲜度:被匹配的字节仍然来自模型所读取的那个版本即可。 -旧 RFC 已经推迟了独立的 `@deepseek-ai/dsh-fs-policy` 包(package)。本 RFC 构建该层,并让 `ctx.fs` 贴近 fsspec 风格的存储原语(`info`/`cat`/`open`),但不将其变成完整的 fsspec。 +旧 Agent Note(agent 决策记录)已经推迟了独立的 `@deepseek-ai/dsh-fs-policy` 包。本 Agent Note 构建该层,使 `ctx.fs` 保持接近 fsspec 风格的存储原语(`info`/`cat`/`open`),但不把它变成完整的 fsspec。 ## 决策 @@ -30,14 +30,14 @@ provider dsh-fs-local local implementation of ctx.fs `dsh-tool-fs` 保持相同的面向模型的 `read`/`write`/`edit` schema。它是执行器:注入 `fs`(不是策略服务)并直接访问 `ctx.fs`,拥有读取窗口化逻辑,并分发 `fs/*` 事件以便 `dsh-fs-policy` 进行门控和记录。 -本 RFC 决定了四层拆分、提供方契约和新鲜度策略。工具↔策略的耦合方式随后由[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 细化:`dsh-fs-policy` 是一个门控插件,通过 `fs/*` 事件参与而非提供 `ctx.fileContext` 方法服务,因此工具不与它产生方法耦合,读取窗口化与 fs I/O 留在 `dsh-tool-fs` 中。本文描述的是最终落地的事件门控形态;提供方的版本守卫是可选的(省略 = 无条件裸提供方)。 +本 Agent Note 决定了四层拆分、provider 契约和新鲜度策略。随后,[事件门禁 Agent Note](../architecture/2026-06-26-file-context-as-event-gate.md) 细化了工具↔策略耦合:`dsh-fs-policy` 是通过 `fs/*` 事件参与的门禁插件,而非 `ctx.fileContext` 方法服务,因此工具不会在方法层与其耦合;读取窗口和 fs I/O 位于 `dsh-tool-fs`。本文描述已经落地的事件门禁形状;provider 的版本守卫可选(省略即无条件裸 provider)。 ## 提供方契约 `@deepseek-ai/dsh-fs` 收缩为提供方文本 IO 加受保护的文本变更: ```ts ignore-check -abstract resolve(path: string): Promise<FsTarget> +abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string> abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> @@ -65,11 +65,11 @@ type FsWriteIntent = 这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半个层次。UTF-8 解码、二进制/NUL 拒绝、受保护的全文件写入和受保护的字面文本编辑都在提供方内完成,因此策略层从不接触原始字节、不重新实现跨分片解码、也不将陈旧检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、观测状态存储都不会泄漏下去。 -从 `dsh-fs` 中删除的内容:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody`,以及观测状态 `WeakMap`。`applyEdit` 被更窄的提供方原语 `editText` 取代,后者的契约是版本守卫的字面文本变更,而非策略层的读取授权。`FS_PARTIAL_OBSERVATION` 错误码也从 `FsErrorCode` 分类体系中移除:新鲜度授权没有 partial/full 之分,因此没有什么能触发它。`FsTargetKey` 和 `FsVersion` 按照既有的 [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md) 成为品牌化的不透明 id。 +从 `dsh-fs` 删除:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody` 和 observed-state `WeakMap`。`applyEdit` 由更窄的 provider 原语 `editText` 取代,其契约是带版本守卫的字面文本变更,而非策略层读取授权。`FS_PARTIAL_OBSERVATION` code 也从 `FsErrorCode` 分类中移除:新鲜度授权没有部分/完整之分,因此没有任何路径会抛出它。`FsTargetKey` 和 `FsVersion` 按现有[品牌化 id Agent Note](../architecture/2026-06-20-branded-ids.md) 成为品牌化不透明 id。 ## 策略契约 -`@deepseek-ai/dsh-fs-policy` 是一个插件,不是服务:它不注册任何 `ctx.*` 键,也不注入任何东西。它拥有写入/编辑新鲜度策略和观测状态,这些不属于 `FileSystem` 提供方基类(否则沙箱/远程后端会继承它无需承载的面向模型的观测策略)。它通过执行器分发的 `fs/*` 事件门控贡献该策略。(本 RFC 最初提出了一个具体的 `ctx.fileContext` 方法服务,带有 `read`/`write`/`edit` 方法;[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 将其改造为此处描述的门控插件,使工具从不与策略产生方法耦合。) +`@deepseek-ai/dsh-fs-policy` 是插件,而非服务:它不注册任何 `ctx.*` 键,也不注入任何内容。它拥有不应位于 `FileSystem` provider 基类上的写入/编辑新鲜度策略和 observed state(否则 sandbox/远程后端会继承不该由其承载的面向模型观察策略)。它通过 executor 分派的 `fs/*` 事件门禁贡献该策略。(本 Agent Note 最初提议带有 `read`/`write`/`edit` 方法的具体 `ctx.fileContext` 服务;[事件门禁 Agent Note](../architecture/2026-06-26-file-context-as-event-gate.md) 将其细化为本文所述插件,使工具永远不会在方法层与策略耦合。) 观测状态以 `WeakMap<owner, Map<targetKey, FsVersion>>` 的形式存放于此。当且仅当 owner 读取、写入或编辑过该目标时,条目才存在(每次成功都会发出 `fs/observed`),因此条目的存在*本身就是*先前观测的记录——没有单独的 `hasRead` 标志。owner 从不透明的事件 actor(`{ agent?: { session? } }`)结构化派生,该形状定义在 `dsh-fs-policy` 中而非 `dsh-fs` 中。 @@ -99,7 +99,7 @@ type FsWriteIntent = ## 取代 -本 RFC 逆转了 [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) 中的两项决策,并收窄了第三项: +本 Agent Note 推翻[文件系统能力接缝](../architecture/2026-06-17-filesystem-capability-seam.md)中的两项决策,并收窄第三项: - 读后写/编辑策略从 `ctx.fs` 移出,进入 `dsh-fs-policy` 插件(通过 `fs/*` 事件门控)。 - 文本读取不再返回后端编号的行记录或 `full`/`partial` 视图;授权基于版本新鲜度,因此窗口化读取在文件未变时即可授权编辑。 @@ -113,12 +113,12 @@ type FsWriteIntent = ## 后续扩展 -该 seam 后来由 [Add direct directory listing to the filesystem seam](../architecture/2026-07-03-filesystem-directory-listing-seam.md) 扩展了直接目录列表功能。该后续工作单独跟踪,以使本 RFC 的验收标准继续描述最初交付的 fsspec 风格重构。 +后来,[为文件系统接缝添加直接目录列表](../architecture/2026-07-03-filesystem-directory-listing-seam.md)进一步扩展了该接缝。该后续工作单独跟踪,使本 Agent Note 的验收标准继续描述最初落地的 fsspec 风格改造。 ## 曾考虑的替代方案 - **字节级 fsspec(`cat`/`open` 返回原始字节)**:否决。该 seam 刻意定位为文本存储,比字节级高半个层次,这样 UTF-8 解码、二进制/NUL 拒绝和受保护的文本变更只在提供方实现一次,策略层从不接触原始字节,也不将陈旧检查与变更临界区分离。 -- **具体的 `ctx.fileContext` 方法服务**:本 RFC 最初的策略形态;被[事件门控 RFC](../architecture/2026-06-26-file-context-as-event-gate.md) 改造为门控插件,使工具从不与策略产生方法耦合。 +- **具体的 `ctx.fileContext` 方法服务**——本 Agent Note 最初的策略形状;[事件门禁 Agent Note](../architecture/2026-06-26-file-context-as-event-gate.md) 将其重做为门禁插件,使工具永远不会在方法层与策略耦合。 - **在提供方保留 `readPage` 和 `full`/`partial` 视图授权**:「取代」一节所逆转的重构前形态。视图完整性不是编辑安全所需的,版本新鲜度才是;而视图规则使超过读取上限的大文件无法编辑。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 99d356c3e8..6b26f18d77 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-remove-stream-chunk-mirror.md: 74843cf49c043d46d44266a7bb0d8c953a749b6f -2026-07-02-remove-stream-chunk-mirror.zh.md: 1b460b4600442535d2572770449ce4dbcc836fe8 +2026-07-02-remove-stream-chunk-mirror.md: 5dd816940a4b2c2b63980e01f1bd4e0aac53a3e2 +2026-07-02-remove-stream-chunk-mirror.zh.md: d4bfe540ecb3f938721f2b387f302caf163b0f94 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index 1b460b4600..d4bfe540ec 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -1,4 +1,4 @@ -# RFC: 停止将 token 流镜像为 agent 事件 +# Agent Note: 停止将 token 流镜像为 agent 事件 Status: implemented @@ -19,7 +19,7 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror 实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方直接丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 -这与[边界镜像移除](2026-06-20-remove-agent-boundary-mirror-events.md)为 turn/step 边界消除的重复如出一辙:消费方对同一个持久事实有两个真源,每次变更都要同时修改两处。那份 RFC 将 chunk 流推迟处理(「`assistant/chunk` 的持久化仍然是承重的,因此 chunk 流后续可以作为镜像来评估,但那是一个独立决策」),而非一并纳入。本 RFC 即是那个独立决策。 +这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为 turn/step 边界消除的重复相同:消费者面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把 chunk 流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将 chunk 流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 推迟所依赖的前提已经明确:chunk 持久化是权威的,且将保留。停止持久化 chunk、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 @@ -27,15 +27,15 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror 从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化与回放已经使用的正是同一个序列。`session/event` 是唯一的实时 transcript(文本记录)流(assistant chunk、turn/step 边界、工具活动、todo)。 -**消费方。** 唯一重要的生产消费方——ACP 桥接(`dsh-acp`,面向编辑器的真实流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其 chunk 渲染被折叠进该监听器作为 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立监听器(`agent/stream-chunk` 和 `session/event`)之间共享,chunk 与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 +**消费方。** 唯一重要的生产消费方——ACP(Agent Client Protocol)桥接(`dsh-acp`,面向编辑器的真实流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其 chunk 渲染被折叠进该监听器作为 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立监听器(`agent/stream-chunk` 和 `session/event`)之间共享,chunk 与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 ## 范围 移除:`agent/stream-chunk`。 未触及: -- `assistant/chunk`(持久会话事件)——权威的 token 流,原样保留。本 RFC 移除的是实时镜像,而非持久化(持久化移除提案已被单独否决,见上文)。 -- `agent/steering`——本决策未触及(它是控制信号,不是 token 流)。其持久孪生事件是 `steering/message`,镜像发射由其自身的后续 RFC 移除:[移除 `agent/steering` 镜像发射](2026-07-04-remove-agent-steering-mirror.md)。 +- `assistant/chunk`(持久 session 事件)——权威 token 流,原样保留。本 Agent Note 移除的是实时镜像,而非持久化(移除持久化的提案已单独遭到拒绝——见上文)。 +- `agent/steering`——本决策未触及(它是控制信号,不是 token 流)。其持久孪生事件是 `steering/message`,镜像发射由其自身的后续 Agent Note 移除:[移除 `agent/steering` 镜像发射](2026-07-04-remove-agent-steering-mirror.md)。 - `agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`、`agent/session-start`——生命周期/控制事件,不是 transcript 数据,也没有持久副本。 ## 曾考虑的替代方案 @@ -44,4 +44,4 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror ## 后果 -插件不再能通过以 `Agent` 为首参的事件观察 token delta。它需要订阅 `session/event` 并过滤 `assistant/chunk`(如需 `Agent` 句柄,可通过 `agent/created`/`agent/disposed` 构建的 session-id→agent 映射恢复,与边界消费方已有的做法完全一致)。没有任何生产消费方在 chunk 时需要实时的 `Agent`;这与边界镜像移除所做的权衡相同,是可接受的。 +插件不能再从 `Agent` 优先事件观察 token 增量。它需要订阅 `session/event`、过滤 `assistant/chunk`,并在需要时通过 `ctx.agents.get(session.id)` 直接查找对应的实时 handle。没有生产消费者需要在 chunk 时刻取得实时 `Agent`;这与移除边界镜像所作的取舍相同,均可接受。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml index 8a31f5ee11..cf67ddfcd5 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-drop-image-content-block.md: 145f805cbe335d3b8275bef6a2bb1fcbe1bd3df3 -2026-07-04-drop-image-content-block.zh.md: ba77caeba4cb1fdd3ff95f4cd498c87cdf1aa227 +2026-07-04-drop-image-content-block.md: 566803ab5b9f213b7dc87fcf779ed7a963d988fd +2026-07-04-drop-image-content-block.zh.md: 27282bbf8768cafaf3fff696559b81756af5a45e diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md index ba77caeba4..27282bbf87 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除 `image` 内容块,直到有路径能真正处理它 +# Agent Note: 移除 `image` 内容块,直到有路径能真正处理它 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其丢弃:deepseek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP 编解码器既不宣告 image prompt 能力、也不向外转发 image 块,并且会拒绝入站的 image prompt 内容;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 +`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其丢弃:deepseek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP(Agent Client Protocol)编解码器既不宣告 image prompt 能力、也不向外转发 image 块,并且会拒绝入站的 image prompt 内容;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 ## 决策 @@ -22,7 +22,7 @@ Status: implemented ## 验证 -RFC 记录之外没有任何地方构造 harness 的 `ImageBlock`。ACP 独立的入站 image 拒绝仍有测试覆盖,适配器、编解码器和压缩的默认分支则通过插件定义的块类型来覆盖。 +除 Agent Note(agent 决策记录)之外,没有任何地方构造 harness `ImageBlock`。ACP 独立的入站图像拒绝路径仍有测试;adapter、codec 和压缩的默认分支则使用插件定义的块类型覆盖。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml index 429e5ae268..f55ba68808 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-drop-inert-request-knobs.md: 86d0dfefe1bdfb0c49b5b9080935441d16dd2223 -2026-07-04-drop-inert-request-knobs.zh.md: f7d969378af7e070ecee82e8ea1a569c61208208 +2026-07-04-drop-inert-request-knobs.md: 06fa6c1c539f9ff0cfabf76bc41c53800bd46c8c +2026-07-04-drop-inert-request-knobs.zh.md: 69a8b0ca498f9fe11df5eb1f3207d88f66a7b70b diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md index f7d969378a..69a8b0ca49 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除 `GenerateOptions.prefill` 与 `ToolSchema.strict`——无端到端可用路径的请求旋钮 +# Agent Note: 移除 `GenerateOptions.prefill` 与 `ToolSchema.strict`——无端到端可用路径的请求旋钮 Status: implemented @@ -15,10 +15,10 @@ Status: implemented ## 决策 -- 从 `GenerateOptions` 中移除 `prefill`,同时移除两个适配器的 UNSUPPORTED 守卫、固定这些 throw 的测试、[core.md](../../../core-data-structures/core.md) 中的粘贴行,以及适配器 README 中记录拒绝行为的行。实操手册(cookbook)中的 UNSUPPORTED 指引([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md))改为泛化表述——你的 provider 无法兑现的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的后果部分将 prefill 记录为「受 producer 门控」而非「已有归属」,依据 [implemented/AGENTS.md](../AGENTS.md)。 +- 从 `GenerateOptions` 中移除 `prefill`,同时移除两个 adapter 的 UNSUPPORTED 守卫、固定抛错行为的测试、[core.md](../../../../docs/core-data-structures/core.md) 中的粘贴行,以及记录该拒绝行为的 adapter README 表格行。cookbook 中的 UNSUPPORTED 指导([adding-an-llm-adapter.md](../../../../docs/cookbook/adding-an-llm-adapter.md))改为通用表述规则——provider 无法遵守的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 Agent Note(agent 决策记录)](../architecture/2026-06-11-content-block-vocabulary.md)的后果按照 [implemented/AGENTS.md](../AGENTS.md),将 prefill 记录为由生产者门控,而不是已有归属。 - 从 `ToolSchema`、`DefineToolOptions`、`defineTool`、`schemas()` 允许列表、deepseek 序列化分支及其 wire-type 字段,以及 tool-catalog 渲染器的 `Strict:` 行中移除 `strict`。pi-ai 的 payload 修补逻辑简化为对 pi-ai 自身逐工具 strict 默认值的无条件清除(pi-ai 在每个序列化的工具上打 `strict: false`;手写的孪生适配器不发送此字段,因此清除逻辑为保持协议格式对等而保留,由其序列化器测试固定)。setter 测试和 core.md 粘贴行已移除;`GenerateOptions` 与 `ToolSchema` 在 `scripts/type-equiv.manifest.json` 中保留各自的行,因为两个类型只是少了一个字段,本身仍然存在。 -本 RFC 有意不触碰 `temperature`、`stop` 或 `maxTokens`:它们在两个适配器中都被端到端地兑现,是 `agent/request` 上请求变更钩子插件的自然首选目标。 +本 Agent Note 刻意不触及 `temperature`、`stop` 或 `maxTokens`:两个 adapter 都会端到端遵守它们,而且它们自然是 `agent/request` 上修改请求的 hook 插件首批目标。 ## 曾考虑的替代方案 @@ -28,7 +28,7 @@ Status: implemented ## 验证 -`rg prefill` 仅返回 RFC 记录(本 RFC 与[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 中 producer-gated 的后果);在 tool-schema 范围内执行 `rg strict` 仅返回本 RFC、保留的 pi-ai 清除逻辑,以及 `strictEqual` 等无关文本。两个适配器的契约测试在移除守卫后通过,pi-ai 修补逻辑仍然清除库的 strict 默认值——协议格式对等由其序列化器测试固定。 +`rg prefill` 只返回 Agent Note 记录(本文及[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)中由生产者门控的后果);限定在工具 schema 范围内的 `rg strict` 只返回本 Agent Note、保留下来的 pi-ai 清理逻辑,以及 `strictEqual` 等无关正文。两个 adapter 的契约测试都能在没有守卫的情况下通过,pi-ai 修正仍会清理库的 strict 默认值——其 serializer 测试固定了线协议一致性。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml index d86354d68a..5344a28971 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-drop-unconsumed-web-observation-surface.md: ba97076eef385cd218517f233ed44f86d3f7eb7a -2026-07-04-drop-unconsumed-web-observation-surface.zh.md: 83c2e786d4edde7b7c94cf2c028b14e20da842b4 +2026-07-04-drop-unconsumed-web-observation-surface.md: 5b1cb1307c63ef7c298200ee1655119026b9ebf5 +2026-07-04-drop-unconsumed-web-observation-surface.zh.md: a48e74eeb0f718c3511e2b1ad8e2dcde635350e1 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md index 83c2e786d4..a48e74eeb0 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除未被消费的 web 观测接口——`providers-change` 事件与 status 方法 +# Agent Note: 移除未被消费的 web 观测接口——`providers-change` 事件与 status 方法 Status: implemented @@ -9,11 +9,11 @@ Status: implemented `WebService` 暴露了一组没有任何生产代码观测的观测接口: - **`web/providers-change`**(`packages/web/web/src/index.ts`)在每次 provider 注册和 dispose(资源释放)时声明并发出,且每个注册 effect 的回滚 yield 被刻意排在 emit 之前,唯一目的是让抛出异常的 change listener 能回退注册。在该包自身的两个单元测试之外没有任何 listener(其中一个测试的存在仅仅是为了固定那个回滚顺序)。 -- **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一个包)没有任何生产调用方:`dsh-tool-web` 通过 `ctx.web.search()`/`fetch()` 直接执行,并将不可用性表现为 seam 在执行时抛出的结构化 `WebError` 错误码(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自身的测试。`packages/web/tool-web/README.md` 和 [architecture.md](../../../architecture.md) 中的行文声称该工具「只读取聚合的 `searchStatus()`/`fetchStatus()`」——这是一处漂移,仅因没有机制检查行文与调用点的一致性而幸存。 +- **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一包)没有生产调用方:`dsh-tool-web` 直接通过 `ctx.web.search()`/`fetch()` 执行,并把不可用性呈现为接缝在执行时抛出的结构化 `WebError` code(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自己的测试。`packages/web/tool-web/README.md` 和 [architecture.md](../../../../docs/architecture.md) 中的正文声称工具“只读取聚合的 `searchStatus()`/`fetchStatus()`”——这种漂移之所以存续,只是因为没有机制对照调用位置检查正文。 seam 自身的设计使这两个接口天然没有消费方:工具注册跟随产品 ENABLEMENT 而非 provider 可用性(`packages/web/tool-web/src/index.ts`),provider 选择在执行时解析且从不缓存——因此没有需要失效的缓存、没有需要重算的注册集合、也没有调用方需要一个有别于「执行并路由结构化错误」的可用性探测。HMR(热模块替换)清理由 effect disposer 自身承载。 -这与 [移除未被消费的 `llm/adapter-change` 事件](../../implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) 如出一辙:那个 RFC 从 `LlmService` 中移除了相同的通知形态、相同的 rollback-before-emit 机制和相同的 listener-throw 测试。该 RFC 的保留/裁剪判据——为 `tools/change` 保留其合理的面向用户的工具列表消费方,裁剪启动时的后端注册表信号——将 web provider 注册表明确归入裁剪一侧;status 方法是同一判断应用于 pull 接口而非 push 接口。 +这与[删除无人消费的 `llm/adapter-change` 事件](2026-06-20-drop-unconsumed-llm-adapter-change-event.md)相呼应;后者从 `LlmService` 移除了相同的通知形状、相同的 emit 前回滚机制和相同的监听器抛错测试。该 Agent Note(agent 决策记录)的保留/删除标准是:为可能面向用户的工具列表消费者保留 `tools/change`,删除启动时后端注册表信号。按这一标准,web provider 注册表明确属于删除一侧;status 方法则是把同一判断应用于拉取表面,而非推送表面。 ## 决策 @@ -23,11 +23,11 @@ seam 自身的设计使这两个接口天然没有消费方:工具注册跟随 ### 为什么不保留? -web seam RFC 有意指定了两者——事件作为最小的 HMR 可见性信号,status 方法作为工具的聚合诊断——且未来的 provider 状态面板是可以想象的。但同一 RFC 的其他设计选择使它们失去了消费方:按需派生的选择与基于 enablement 的注册使得没有消费方能需要这两者;已交付的工具展示了真实模式(执行并路由结构化错误);漂移的 README 语句表明承诺的消费方从未实现。按 AGENTS.md「RFC 是提案,不是金科玉律」的原则,这些是该提案中代码已证明过度设计的部分;未来的观测者按其实际消费的需求重新引入最小的信号或查询,由该消费方塑造其形态。 +web 接缝 Agent Note 刻意规定了两者——事件作为最小 HMR 可见性信号,status 方法作为工具的聚合诊断——未来也可以设想 provider 状态面板。但同一 Agent Note 的其他选择让它们失去了生存条件:调用时派生选择和基于启用状态的注册,使任何消费者都不可能需要其中任一项;已发布工具展示了真实模式(执行并路由结构化错误);发生漂移的 README 句子则表明承诺中的消费者从未出现。按照 AGENTS.md 所述“Agent Note 是提案,而非绝对真理”,代码后来证明提案中的这些部分超出了需要;未来的观察者应根据真实消费者的形状,重新引入它实际消费的最小信号或查询。 ## 验证 -在 RFC 历史之外不再有 `providers-change`、`searchStatus`、`fetchStatus` 或 `WebCapabilityStatus` 的拼写残留;catalog 是最新的(`verify-cordis-catalog` 绿色);注册/释放的 HMR 安全测试通过执行行为证明清理正确;tool-web README 与 architecture 段落描述了工具实际拥有的执行时错误路由契约。 +除 Agent Note 历史外,不再存在 `providers-change`、`searchStatus`、`fetchStatus` 或 `WebCapabilityStatus` 拼写;目录保持新鲜(`verify-cordis-catalog` 为绿色);注册/释放 HMR 安全性测试通过执行行为证明清理;tool-web README 和架构段落也描述了工具实际拥有的执行时错误路由契约。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml index a43d663e93..b2361b71d4 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-fold-stdio-ui-helper.md: ab42f1d131f6c657edf078d953c646b7970e9782 -2026-07-04-fold-stdio-ui-helper.zh.md: 2ad6abf3d5fb5ae6cdd852f8b5ec7e061e62b8ed +2026-07-04-fold-stdio-ui-helper.md: 01165df19942e8e0adf84ede3a371f53ad12e464 +2026-07-04-fold-stdio-ui-helper.zh.md: 5efaf47694092b4ba02208330dccf6b9bb086fca diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index b05dd22357..01165df199 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-04-fold-stdio-ui-helper.zh.md) + The later [redundant-agent removal](2026-07-20-remove-stdio-and-echo-agents.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely. ## Problem diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md index 2ad6abf3d5..5efaf47694 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md @@ -1,9 +1,11 @@ -# RFC: 将 stdio UI 辅助模块折入 stdio 应用 +# Agent Note: 将 stdio UI 辅助模块折入 stdio 应用 Status: implemented [English](2026-07-04-fold-stdio-ui-helper.md) | 中文 +后来的[冗余 agent(智能体)移除](2026-07-20-remove-stdio-and-echo-agents.md)取代了这项包放置决策,并完整移除合并后的包、应用和面向行的表面。 + ## 问题 readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/dsh-ui-stdio`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载应用来使用 readline UI,从不自行组合该辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest(元数据清单)与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在,`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被明确标注为非产品表面的 support 包。 @@ -12,15 +14,15 @@ readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/ ## 决策 -该辅助模块作为终端通道插件存放在 `@deepseek-ai/dsh-stdio` 中(`packages/ui/stdio/src/index.ts`):`createStdioChat`、其 `StdioRuntime` 测试 seam 及单元测试(`packages/ui/stdio/tests/stdio.spec.ts`、`readline.spec.ts`)一并迁入,因此 EOF 处理、渲染、dispose(资源释放)以及管道/TTY 行为在按文件覆盖率门禁下仍有单元测试覆盖,且无需劫持进程全局对象。该模块保留具名的 `name`/`inject`/`Config`/`apply` 导出形状——即应用的 `ctx.plugin(uiStdio, …)` 挂载所消费的契约——而 `examples/echo-agent` 与 `examples/coding-agent` 中的 keyless Loader 路径冒烟测试继续证明组合树能通过真实 Loader 启动(stdio 包的插件形状单元测试套件固定了显式的 `unwrapExports` 断言,因为缺少 `inject` 的 bundle 会跳过一个意外的 default 导出而不是崩溃)。 +当时,该辅助函数移入 `@deepseek-ai/dsh-stdio`,成为终端通道插件。`createStdioChat`、其 `StdioRuntime` 测试接缝和单元测试随之一同迁移,使 EOF 处理、渲染、释放以及管道/TTY 行为继续受逐文件覆盖率门禁约束,而不会劫持进程全局量。该模块保留应用挂载所消费的具名 `name`/`inject`/`Config`/`apply` 导出形状;当时的 Echo 和 REPL Loader 冒烟证明组合树,插件形状套件则固定显式 `unwrapExports` 行为。上方取代本文的移除记录负责当前包和示例状态。 -`packages/support/ui-stdio` 包已移除:manifest、tsconfig 引用、module-graph 行与 README 行均已删除;曾命名该包的文档注释(示例 e2e 模块文档、`packages/README.md`、support 与 todo README、[ui 组 README](../../../../packages/ui/README.md))现在描述的是包内模块。 +早期的支持辅助包已移除:其清单、tsconfig 引用、模块图行和 README 行均已消失,其余文档改为描述包内模块。 ## 曾考虑的替代方案 ### 为什么不将其提升到 `ui/` 而是折入? -提升可以解决 support 与 product 之间的错位,同时保留边界——只有在 readline UI 是一个可独立替换的集成或有第二个组合方时才是正确选择,而消费方普查表明两者皆非。结构化的 ACP 桥接保留为独立包,因为它是具有自身契约和快照层级的产品协议表面;readline 辅助模块只是一个应用前门的脚手架。在发布前重新拆分成本很低:如果将来有第二个产品应用需要 readline UI,届时再拆出来,由那个消费方来塑造包契约。 +提升可以解决 support 与 product 之间的错位,同时保留边界——只有在 readline UI 是一个可独立替换的集成或有第二个组合方时才是正确选择,而消费方普查表明两者皆非。结构化的 ACP(Agent Client Protocol)桥接保留为独立包,因为它是具有自身契约和快照层级的产品协议表面;readline 辅助模块只是一个应用前门的脚手架。在发布前重新拆分成本很低:如果将来有第二个产品应用需要 readline UI,届时再拆出来,由那个消费方来塑造包契约。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml index 1d12fb892b..1c00f9b991 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-producerless-vocabulary-variants.md: f1b80e35b9004e40fb0ffdd08310310848912d09 -2026-07-04-prune-producerless-vocabulary-variants.zh.md: 9e7b55256ba5bda0474fd9056eea9a96beaf8096 +2026-07-04-prune-producerless-vocabulary-variants.md: 34492e6906cd2d795f880310b1bcd120e3953fcf +2026-07-04-prune-producerless-vocabulary-variants.zh.md: 710f64d7e848bcabb8a1bc0c0c8be9af7b2ca1b0 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md index aa61e859d2..34492e6906 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-04-prune-producerless-vocabulary-variants.zh.md) + ## Problem The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violated that policy — each had no producer and no consumer, and two had not even a test: diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md index 9e7b55256b..710f64d7e8 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md @@ -1,4 +1,4 @@ -# RFC: 裁剪无生产者的词汇变体(块缓存提示、`agent` 消息来源、`continuation` 轮次触发器) +# Agent Note: 裁剪无生产者的词汇变体(块缓存提示、`agent` 消息来源、`continuation` 轮次触发器) Status: implemented @@ -8,13 +8,13 @@ Status: implemented 可合并扩展的词汇映射表设计上通过声明合并来增长,代码库已在 `TurnEndReasonMap`(`packages/core/session/src/types.ts`)上明确了准入策略:像 `refusal` 这样的变体「在适配器或循环首次发出它之前,有意不纳入」。三个已声明的词汇项违反了该策略——每个都既无生产者也无消费方,其中两个甚至没有测试: -- **`CacheHint` 及其 `cache?: CacheHint` 块字段**,位于 `TextBlock`/`ToolResultBlock`(`packages/llm/llm/src/types.ts`;image block 上还有第三个同类字段,已随 image block 一起移除——见[移除 image block 的 RFC](2026-07-04-drop-image-content-block.md))。没有任何地方构造过带 `cache:` 的块——src、测试和文档粘贴全部搜索为空——两个适配器也都不读 `.cache`:DeepSeek 的 prompt 缓存是自动的,适配器只从响应中映射出 `prompt_cache_hit_tokens`,从不向请求中发送提示。这是 Anthropic 风格的 `cache_control` 接口面,却没有能兑现它的提供方。 +- **`TextBlock`/`ToolResultBlock` 上的 `CacheHint` 及其 `cache?: CacheHint` 块字段**(`packages/llm/llm/src/types.ts`;图像块曾有第三个此类字段,已随图像块一同移除——参见[删除图像 Agent Note(agent 决策记录)](2026-07-04-drop-image-content-block.md))。任何地方都没有构造带 `cache:` 的块——src、测试和文档粘贴均为空——两个 adapter 也都不读取 `.cache`:DeepSeek prompt caching 是自动的,因此 adapter 会从响应中映射出 `prompt_cache_hit_tokens`,却从不向请求中发送 hint。这是没有任何 provider 能够遵守的 Anthropic 风格 `cache_control` 表面。 - **`MessageSourceMap.agent`**(`{ kind: 'agent'; agentId: string }`,同一文件)。零个构造点,包括测试在内。它预期的生产者在实现时并未使用它:subagent 后端将父级的 prompt 发送给子级时不带 `source`,因此记录为 `{ kind: 'user' }`,通用信封渲染器在插值 `source.kind` 时也从未对其做路由。 -- **`TurnTriggerMap.continuation`**(`packages/core/session/src/types.ts`)。agent loop(智能体循环)在结构上不可能发出它——continuation 发生在一个轮次*内部*作为后续步骤,而非作为新轮次——循环只构造 `message` 和 `injection` 触发器。唯一的写入者是一个手工构建的测试 fixture(测试前置数据),它只需要一个任意的非 message 触发器(`packages/support/llm-replay/tests/llm-replay.spec.ts`),`injection` 触发器同样满足需求;唯一的生产环境触发器读取方 ACP 桥接层只过滤 `kind === 'message'`。 +- **`TurnTriggerMap.continuation`**(`packages/core/session/src/types.ts`)。agent loop(智能体循环)在结构上不可能发出它——continuation 发生在一个轮次*内部*作为后续步骤,而非作为新轮次——循环只构造 `message` 和 `injection` 触发器。唯一的写入者是一个手工构建的测试 fixture(测试前置数据),它只需要一个任意的非 message 触发器(`packages/support/llm-replay/tests/llm-replay.spec.ts`),`injection` 触发器同样满足需求;唯一的生产环境触发器读取方 ACP(Agent Client Protocol)桥接层只过滤 `kind === 'message'`。 ## 决策 -删除 `CacheHint`、其 `cache?` 块字段、`agent` 消息来源变体与 `continuation` 轮次触发器变体:发布的词汇表不再包含它们。llm-replay fixture 改用 `injection` 触发器(任何非 `message` 触发器均满足其用途)。[core.md](../../../core-data-structures/core.md) 和 [session.md](../../../core-data-structures/session.md) 中的 type-equiv 粘贴与裁剪后的映射表一致——两个符号保留在 `scripts/type-equiv.manifest.json` 中,因为每个映射表本身仍然存在,只是少了一个成员——[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 的后果部分将缓存提示记录为「受生产者门控」而非「已有归属」,依照 [implemented/AGENTS.md](../AGENTS.md)。 +`CacheHint`、其 `cache?` 块字段、`agent` 消息来源变体和 `continuation` turn 触发器变体均已删除:已发布词汇不再携带它们。llm-replay fixture 使用 `injection` 触发器(任何非 `message` 触发器都能满足其用途)。[core.md](../../../../docs/core-data-structures/core.md) 和 [session.md](../../../../docs/core-data-structures/session.md) 中的 type-equiv 粘贴与裁剪后的 map 匹配——两个符号仍保留在 `scripts/type-equiv.manifest.json` 中的行,因为每个 map 都只是少了一个成员而继续存在——并且[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)的后果按照 [implemented/AGENTS.md](../AGENTS.md),将 cache hint 记录为由生产者门控,而不是已有归属。 每个变体在获得真正的生产者之日回归,这正是映射表设计的增长方式:缓存功能连同传输它的适配器一起重新添加 `cache`;subagent 归属连同打标的后端和路由它的消费方一起重新添加 `agent`;真正启动新轮次的自动续行功能连同发出它的插件一起重新添加 `continuation`。 @@ -22,12 +22,12 @@ Status: implemented ### 为什么不保留它们? -[内容块词汇 RFC](../architecture/2026-06-11-content-block-vocabulary.md) 将「缓存提示……已有归属」列为设计后果,预留槽位确实能表达意图。但一个空槽位是每个实现和消费方都必须考虑的契约面(我的适配器需要兑现 `cache` 吗?我的渲染器需要路由 `agent` 来源吗?),而同族映射表自身的 JSDoc 已经拒绝了「无发出者的预留」——`refusal` 和 `max_turn_requests` 被明确标注为*当某物首次发出它们时*再添加的变体,而非提前声明。对已声明但无生命的变体施加同样的标准,使词汇表具有实际意义:如果它在映射表中,就一定有东西在生产它。 +[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)曾把“cache hint……有了归属”列为设计后果,预留槽位也确实能表明意图。但空槽位是每个实现和消费者都必须考虑的契约表面(我的 adapter 是否必须遵守 `cache`?我的 renderer 是否必须路由 `agent` 来源?),而相邻 map 自身的 JSDoc 已经拒绝“无 emitter 先预留”——`refusal` 和 `max_turn_requests` 被点名为*首次有内容发出它们时*再添加的变体,而不是提前声明。让已经声明但无用的变体遵守同一标准,才能使词汇真正有意义:只要它位于 map 中,就必须有内容生产它。 ## 验证 -对 `CacheHint`、`agent` 消息来源拼写和 `continuation` 触发器拼写执行 `rg` 搜索,结果仅返回 RFC 记录(本文,以及[移除 image block 的 RFC](2026-07-04-drop-image-content-block.md) 中关于 image block 自身 `cache` 字段的说明);llm-replay fixture 使用 `injection` 触发器断言了相同的回放行为;core-data-structures 粘贴与 type-equiv manifest 保持同步。 +对 `CacheHint`、`agent` 消息来源拼写和 `continuation` 触发器拼写运行 `rg`,只会返回 Agent Note 记录(本文,以及[删除图像 Agent Note](2026-07-04-drop-image-content-block.md)对图像块自身 `cache` 字段的说明);llm-replay fixture 使用 `injection` 触发器断言相同的重放行为;核心数据结构粘贴和 type-equiv 清单保持同步。 ## 后果 -没有任何运行时行为改变——本来就没有东西能构造这些值。镜像事件的移除([boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md)、[stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md))只涉及瞬态的 `agent/*` 事件,从不涉及持久词汇,因此不存在冲突。其他地方准入策略已经成立:`rejected`、`prompt/blocked` 和 `hook/invoked`/`hook/result` 各自都有活跃的生产者——本 RFC 将同一标准延伸到缺少生产者的三个变体。image block 自身的 `cache?` 字段属于[移除 image block 的 RFC](2026-07-04-drop-image-content-block.md),已随该块一起移除;本 RFC 覆盖的是留存块类型上的两个字段。 +操作行为没有变化——原本就没有内容能够构造这些值。镜像事件移除([边界镜像 Agent Note](2026-06-20-remove-agent-boundary-mirror-events.md)、[stream chunk Agent Note](2026-07-02-remove-stream-chunk-mirror.md))只触及瞬态 `agent/*` 事件,从不触及持久词汇,因此不存在冲突。其他位置已经遵守准入策略:`rejected`、`prompt/blocked` 和 `hook/invoked`/`hook/result` 都有实时生产者——本 Agent Note 将同一门槛扩展到缺少生产者的三个变体。图像块自身的 `cache?` 字段归属[删除图像 Agent Note](2026-07-04-drop-image-content-block.md),后者将其与该块一同移除;本 Agent Note 覆盖剩余块类型上的两个字段。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml index a595aef18a..e7aabd9dc2 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-write-only-fs-surface.md: f41619ecde1bbf2a1d6d8f8d769409f624fc22c7 -2026-07-04-prune-write-only-fs-surface.zh.md: afcf1aad28db056997930162539a57bb08bbe815 +2026-07-04-prune-write-only-fs-surface.md: 6cfd5d9ab8a2fc6322814d384fba735c06681976 +2026-07-04-prune-write-only-fs-surface.zh.md: cb7494b5e958c8ed86ffa3ba8ffbe82748d1db03 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md b/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md index afcf1aad28..cb7494b5e9 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 从 fs seam 中移除只写字段与一个无效的路由旋钮 +# Agent Note: 从 fs seam 中移除只写字段与一个无效的路由旋钮 Status: implemented @@ -15,7 +15,7 @@ Status: implemented ## 决策 -删除 fs-local 的常量及其重导出,以及 `streamMinSize` 旋钮(`FsIoInternals` 中剩余的旋钮确实被原子写入测试使用);从 `FsTarget` 中移除 `inputPath`;将 `FsEditOutcome` 精简为 `{ version, before, after }`,并将 `replaceAll` 从解析后的参数传入 `formatEditOutput`;从 `FileReadOutcome` 中移除 `limit`/`version`。[filesystem.md](../../../core-data-structures/filesystem.md) 中的粘贴内容、`packages/fs/fs/README.md`,以及那些不得不为已移除字段编造值的测试 mock,都随类型一起缩减。 +删除 fs-local 常量、其再导出和 `streamMinSize` 配置项(其余 `FsIoInternals` 配置项确实由原子写入测试使用);从 `FsTarget` 删除 `inputPath`;将 `FsEditOutcome` 收窄为 `{ version, before, after }`,并把解析参数中的 `replaceAll` 传给 `formatEditOutput`;从 `FileReadOutcome` 删除 `limit`/`version`。[filesystem.md](../../../../docs/core-data-structures/filesystem.md) 中的粘贴、`packages/fs/fs/README.md`,以及不得不虚构已删除字段的测试 fake 都随类型一同收窄。 ## 曾考虑的替代方案 @@ -25,7 +25,7 @@ Status: implemented ## 验证 -被移除的接口已消失——`dsh-fs-local` 中的 `STREAM_MIN_SIZE`/`streamMinSize`、`FsTarget.inputPath`、`FsEditOutcome.replacements`/`.replaceAll`,以及 `FileReadOutcome.limit`/`.version`——而请求侧的 `replaceAll`(`FsEditRequest`)和其他 outcome 类型上的 version 字段未受影响;测试 mock 随类型一起缩减。`formatEditOutput` 在 `replace_all` 两个分支下输出的文本不变,因此没有快照黄金文件被搅动。 +已删除表面不复存在——`dsh-fs-local` 中的 `STREAM_MIN_SIZE`/`streamMinSize`、`FsTarget.inputPath`、`FsEditOutcome.replacements`/`.replaceAll`,以及 `FileReadOutcome.limit`/`.version`——而请求侧 `replaceAll`(`FsEditRequest`)和其他 outcome 类型上的版本字段保持不变;测试 fake 随类型一同收窄。`formatEditOutput` 在两个 `replace_all` 分支中生成的文本都没有变化,因此没有快照预期输出发生改动。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml index ca0bd62294..c676902cfc 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-remove-agent-steering-mirror.md: fbd13b3d43b0052bcdeffd7f94caa341e1f636c5 -2026-07-04-remove-agent-steering-mirror.zh.md: 185cc5601a7406e0d801afd877e9d97eaaa12a0c +2026-07-04-remove-agent-steering-mirror.md: 9f7cd5abe968ff216cbd7012163ea1c04dc00599 +2026-07-04-remove-agent-steering-mirror.zh.md: b498f473941b69eb5eab66e2e3034976da75e62c diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md index 185cc5601a..b498f47394 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除 `agent/steering` 镜像 emit +# Agent Note: 移除 `agent/steering` 镜像 emit Status: implemented @@ -10,23 +10,23 @@ Status: implemented `agent/steering` 以相同的 payload 重复了紧接其前的持久事件 `steering/message`。`agent/queued` 仍保留为纯瞬态信号,因为它在持久化之前触发,覆盖了可能在进入日志前被取消的工作。 -steering 承载着真实的生产流量:hook bridge 的轮次续行决策通过 `inbox.steer()` 注入理由,落地为持久的 `steering/message` 事件,hook-matrix 的 golden 文件对此进行固定——所有这些消费方观察的都是持久事件。没有任何消费方观察镜像事件。 +Steering 承载真实生产流量——hook bridge 的 turn 延续决策通过 `inbox.steer()` 注入其理由,最终成为由 hook 矩阵预期输出固定的持久 `steering/message` 事件——而这些消费者无一例外都观察持久事件。没有任何内容观察镜像。 ## 决策 -`agent/steering` 从 agent 事件分类体系中移除:`packages/core/agent/src/types.ts` 中的声明(及其在 live-events JSDoc 列表中的提及)、`drainSteering` 中的 emit(随之移除的还有当时已无用的 `ctx` 参数)、`packages/core/agent/README.md` 中的对应行,以及 loop 伪代码块中的 emit 行(`packages/core/agent-loop/src/loop.ts` 模块文档与 [architecture.md](../../../architecture.md));Cordis catalog 重新生成后不再包含它。唯一的回归测试改为在持久事件 `steering/message` 上固定 source 保持性——它所固定的事实存在于日志中。 +`agent/steering` 已从 agent 事件分类中移除:包括 `packages/core/agent/src/types.ts` 中的声明(以及其中实时事件 JSDoc 列表对它的提及)、`drainSteering` 中的 emit(当时已无用的 `ctx` 参数也随之移除)、`packages/core/agent/README.md` 中的表格行,以及循环伪代码块(`packages/core/agent-loop/src/loop.ts` 模块文档和 [architecture.md](../../../../docs/architecture.md))中的 emit 行;Cordis 目录重新生成后不再包含它。唯一的回归测试改为在持久 `steering/message` 事件上固定来源保留行为——所固定的事实存在于日志上。 -三份已实施的 RFC 曾声明保留该事件,每份均按 [implemented/AGENTS.md](../AGENTS.md) 的要求修订,指向本 RFC 作为移除记录:[boundary RFC](2026-06-20-remove-agent-boundary-mirror-events.md) 的保留列表条目、[stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md) 的范围条款,以及 [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) 的瞬态 emit 枚举。 +三份已实现 Agent Note(agent 决策记录)曾说明保留该事件;按照 [implemented/AGENTS.md](../AGENTS.md),每份记录都已修改并指向本文作为移除记录:包括[边界 Agent Note](2026-06-20-remove-agent-boundary-mirror-events.md) 的保留列表条目、[stream chunk Agent Note](2026-07-02-remove-stream-chunk-mirror.md) 的范围条款,以及[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的瞬态 emit 枚举。 ## 曾考虑的替代方案 ### 为什么不保留? -"它是控制信号,不是边界事件"——但分类体系的操作性区分是「镜像 vs. 纯瞬态」,而非「控制 vs. 边界」,而这个事件属于镜像。需要入队时通知的消费方有 `agent/queued`(带 steering flag);需要 drain 时通知的消费方,本质上是在请求 `steering/message` 被追加的那一刻,而 `session/event` 以相同 payload 加上持久性提供了这一通知。被否决的 [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) 捍卫的是 steering *能力*——`steer()`、持久事件、续行强制——本次移除对这些全部保持不变。 +“它是控制信号,不是边界”——但该分类的实际区分是镜像/仅实时,而非控制/边界,并且该事件确实是镜像。希望在入队时收到通知的消费者可以使用 `agent/queued`(及其 steering 标记);希望在排空时收到通知的消费者,本质上是在要求获知 `steering/message` 被追加的时刻,而 `session/event` 会交付相同 payload 并附带持久性。遭拒绝的[退役 turn 中途 steering Agent Note](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md)所捍卫的是 steering *功能*——`steer()`、持久事件、强制延续——本次移除不会触及其中任何一项。 ## 验证 -`agent/steering` 这一拼写仅存于 RFC 行文中(本 RFC、上述三份修订的 RFC,以及冻结的[被否决的 steering 能力 RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md),其文本记录了它所拒绝的提案);catalog 已重新生成;重定向后的测试在 `steering/message` 上固定 source 保持性。 +`agent/steering` 拼写只存在于 Agent Note 正文中(本 Agent Note、上方三份已修改 Agent Note,以及已冻结的[遭拒绝 steering 功能 Agent Note](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md),其正文记录了它所否决的提案);目录已重新生成;重新定向的测试在 `steering/message` 上固定来源保留行为。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml index 788e1735b5..32517c2c50 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-share-app-bin-boot-glue.md: 31666e74bb0bb0086de85e6a3afafbc2f73a6e52 -2026-07-04-share-app-bin-boot-glue.zh.md: d18f83be0f76774e39a1e54f1ea2db3c1c1b7688 +2026-07-04-share-app-bin-boot-glue.md: 7a763eba8a229ec5017387edb54657a5c367105b +2026-07-04-share-app-bin-boot-glue.zh.md: d65a6613f7b05cdea0f99529808c992aff4256e9 diff --git a/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md b/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md index d18f83be0f..d65a6613f7 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md @@ -1,4 +1,4 @@ -# RFC: 共享应用 bin 的启动胶水代码,而非维护两份副本 +# Agent Note: 共享应用 bin 的启动胶水代码,而非维护两份副本 Status: implemented @@ -6,19 +6,19 @@ Status: implemented ## 问题 -stdio 和 ACP 两个 bin 各自重复了环境加载、fail-loud 处理、入口校验与启动逻辑,包括微妙的 Loader 失败行为。两份副本已经发生漂移,且位于自执行文件中、被排除在单元测试覆盖率之外,导致其导出的辅助函数无法被复用。 +stdio 和 ACP(Agent Client Protocol)两个 bin 各自重复了环境加载、fail-loud 处理、入口校验与启动逻辑,包括微妙的 Loader 失败行为。两份副本已经发生漂移,且位于自执行文件中、被排除在单元测试覆盖率之外,导致其导出的辅助函数无法被复用。 ## 决策 辅助函数只存在一处:[`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot)(`packages/ui/app-boot`,归入 `ui` 分组,因为 bin 是已发布产物,其运行时依赖本身也必须是已发布的包,而非 `support/`)。包含:`resolveConfigPath`(快照感知,两个 bin 共用的唯一路径解析器)、`loadEnv`、`installFailLoud`、`assertEntriesLoaded` 与 `boot`,每个函数都通过 bin 的诊断前缀参数化,并在其副作用 seam(warn sink、process slice)处支持注入,使单元测试套件能覆盖每个分支——包括 `boot()` 在进程内驱动真实 Loader、使用相对路径 specifier 配置的场景,既覆盖已稳定树的正常路径,也覆盖无 fiber 入口的拒绝路径。该包启用逐文件 100% 覆盖率门禁;Loader 失败的相关知识只有一个归属地。 -每个 `bin.ts` 是一个精简的自执行组合,基于共享辅助函数加上各自特有的应用生命周期(ACP bin:replay 模式下跳过 env 加载与 stdin-EOF dispose;stdio bin:无额外逻辑)。bin 文件仍被排除在覆盖率之外且不导出任何内容;已发布产物的守卫不变——built-bin 冒烟测试仍在 node_modules 形状的临时目录中以原生 node 运行每个 bin(现在也符号链接了 `ui/app-boot`),并仍断言缺少配置时的非零退出码,遵循「真实入口路径即已发布产物」的防御模式。[extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md) 中关于 bin 归属的事实已相应修订。 +每个 `bin.ts` 都是在共享辅助函数之上加应用特有生命周期的精简自执行组合(ACP bin:重放模式环境变量跳过和 stdin EOF 释放;stdio bin:没有额外逻辑)。这些 bin 仍排除在覆盖率之外且不导出任何内容;已发布产物守卫保持不变——按照“真实入口路径即已发布产物”的防御模式,已构建 bin 冒烟仍在具有 node_modules 形状的临时目录中用纯 node 运行每个 bin(现在也会符号链接 `ui/app-boot`),并继续断言缺失配置时以非零状态退出。[提取示例应用包 Agent Note(agent 决策记录)](../architecture/2026-06-20-extract-example-app-packages.md)中的 bin 归属事实已据此修改。 ## 曾考虑的替代方案 ### 为何不保留重复? -bin 被定位为独立拥有的已发布产物,而新增一个包(package)带来的固定开销(manifest(元数据清单)、README、tsconfig reference、publint 表面积)与去重的代码行数相当。但创建 bin 的那份 RFC 从未权衡过应用间共享的可能——它将三份示例 `start.ts` 副本合并进 bin 后便止步了;漂移是已观察到的事实;而覆盖率缺口的论据独立于去重论据:这是仓库中唯一免于逐文件 100% 门禁的非平凡运行时逻辑。记录在案的备选方案(仅将纯逻辑提取为各应用自己的模块)虽能终结豁免,但会保留两个知识归属地。 +这些 bin 当时被定位为归属相互独立的已发布产物,而新包会带来固定开销(清单、README、tsconfig 引用、publint 表面),与去重的行数相当。但创建 bin 的 Agent Note 从未权衡应用间共享——它把三份示例 `start.ts` 副本合并进 bin 后便止步于此;漂移是已经观察到的事实;覆盖率缺口的理由也独立于去重理由:这是仓库中唯一免受逐文件 100% 门禁约束的非平凡运行时逻辑。记录的后备方案(只将纯逻辑提取到各应用模块)会结束豁免,但会继续让相关知识拥有两个归属。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml index b2926299df..a438839d3a 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-tighten-hook-protocol-contract.md: 92ed629edaa0364d88955458f39f6280322e79c7 -2026-07-04-tighten-hook-protocol-contract.zh.md: 9b5b19d7ad522fdf74eb330c259d8dee03bee604 +2026-07-04-tighten-hook-protocol-contract.md: a1972ee8ef486982268ba8886b2413f3557061b4 +2026-07-04-tighten-hook-protocol-contract.zh.md: 51d11b4c2d79bd79b608ea5aa49b677653c3ac41 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md index 9b5b19d7ad..51d11b4c2d 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md @@ -1,4 +1,4 @@ -# RFC: 收紧 hook-protocol 契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 +# Agent Note: 收紧 hook-protocol 契约——dialect、废弃字段、双重默认值与 lib 拥有的 `hook/result` 语义 Status: implemented @@ -6,9 +6,9 @@ Status: implemented ## 问题 -`dsh-hook-protocol`/bridge 契约中有四处遗漏了 [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) 所记录的纪律——该 RFC 因缺乏消费方而移除了 `agentType` 生命周期字段,以下四处未通过同样的检验: +`dsh-hook-protocol`/bridge 契约中有四部分没有遵守 [subagent observe/enrich Agent Note(agent 决策记录)](../feature/2026-06-30-subagent-observe-enrich.md)记下的准则——后者因缺少消费者而删除 `agentType` 生命周期字段,以下各项没有通过同一检验: -1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有任何生产者——bridge 只会标记 `'claude'` 和 `'codex'`;唯一构造 `'native'` 的地方是 lib 自身的单元测试。该字段的 JSDoc 将 `dialect` 定义为「运行它的 bridge」,而 native 并非 bridge:[interception-seams RFC](../feature/2026-06-30-interception-seams.md) 记录了 native hook 不是一个 package,且「native 插件已经可以直接使用类型化的 Decisions」而无需持久化 hook 日志;旗舰 native-plugin 示例也正是如此断言的(完全没有 `hook/*` 事件)。 +1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有生产者——bridge 会标记 `'claude'` 和 `'codex'`;所有位置中唯一构造 `'native'` 的是该库自己的单元测试。字段自身的 JSDoc 将 `dialect` 定义为“运行它的 bridge”,而 native 不是 bridge:[拦截接缝 Agent Note](../feature/2026-06-30-interception-seams.md) 记载 native hook 不是一个包,并且“native 插件无需持久 hook 日志即可使用类型化 Decision”;旗舰 native 插件实践示例恰好断言了这一点(完全没有 `hook/*` 事件)。 2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有 merge fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本无物可抑制:hook stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此 hook 作者设置 `suppressOutput: true` 得到的是无声的空操作,且无任何警告。 3. **`defaultTimeoutMs` 在两个 bridge 配置中以浮动字面量双重默认**——schema 的 `.default(600_000)` 加上一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。*提案最初的补救措施是彻底删除该旋钮,但被 no-hardcoded-tunables 审计所取代:审计保留了该旋钮作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下要修的是字面量的归属地。* 4. **`hook/result` 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。** `summarize()`——stderr 截断规则——在 `packages/hooks/hooks-claude/src/index.ts` 与 `packages/hooks/hooks-codex/src/index.ts` 中逐字节相同;decision 字符串规则 `output.decision ?? (output.continue === false ? 'stop' : 'pass')` 同样如此。然而 `dsh-hook-protocol` 声明了 `hook/result`、在文档中将 `stderrSummary` 描述为「已截断」却不拥有截断逻辑,记录了 decision 值却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享持久化事件的语义就会悄然分叉。 @@ -29,4 +29,4 @@ Status: implemented ## 后果 -`dialect`、`suppressOutput`、可调参数与语义的变更在协议格式(wire format)和 golden 文件中均不可见。代价是 `dsh-hook-protocol` 与两个 bridge 的代码变动——在预发布阶段这很廉价,且比让持久化事件语义的两份副本各自老化要廉价得多。 +`dialect`、`suppressOutput`、可调参数和语义变更在线协议和预期输出中均不可见。代价是 `dsh-hook-protocol` 和两个 bridge 中的改动——在预发布立场下成本很低,也比让一项持久事件语义的两个副本各自老化更便宜。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml index f0afae419b..9de0d532bc 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-trim-acp-bridge-unreachable-surface.md: 05a62c92ec1553e6eb0b14adc86f8aa1b89827e5 -2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 851198eee0559013429ef4eb5491cd7f97217c46 +2026-07-04-trim-acp-bridge-unreachable-surface.md: ce0c623930192d02c8347c3956c497ee13048904 +2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: ea94f28b4802c18394cc3f6e6d4bdfa8702dae2b diff --git a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md index 851198eee0..ea94f28b48 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 裁剪不可达的 ACP 桥接层表面——品牌配置项与 kind 嗅探回退 +# Agent Note: 裁剪不可达的 ACP 桥接层表面——品牌配置项与 kind 嗅探回退 Status: implemented @@ -8,7 +8,7 @@ Status: implemented `dsh-acp` 有两处对外表面在任何已交付的配置中都不可达: -1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已交付的 app 包(`packages/examples/acp-demo/src/index.ts`)只向桥接层传递 `{ model }`,因此没有任何叶子 `cordis.yml`(唯一的生产配置表面)能设置这两个配置项;它们只有通过直接挂载桥接层才能设置,而只有单元测试这样做。所有快照 golden(包括 hook-matrix 场景)都固定了 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对字段还带着一个活跃的 `TODO(double-default)`:字面量存在两份(schema 的 `.default(...)` 加 `??` 回退),TODO 要求选定一个归属。 +1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已发布应用包只向 bridge 传递 `{ model }`(`packages/examples/acp-demo/src/index.ts`),因此没有任何叶子 `cordis.yml`——唯一的生产配置表面——能够设置这些配置项;只有直接挂载 bridge 才能设置它们,而这种做法只存在于一个单元测试中。每份快照预期输出——包括 hook 矩阵场景——都固定 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对配置项还带有一个尚未解决的 `TODO(double-default)`:字面量存在两次(schema `.default(...)` 加 `??` 后备值),TODO 要求为它们选择一个归属。 2. **`toolKindFor` 名称启发式**(同一文件)在通用回退路径中对 `bash*`/`read*`/`write`/`edit*` 工具名做了特殊处理。自 [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) 以来,这些分支匹配到的每个第一方工具都自带 `presentCall` 并携带其 kind,而没有 presenter 的生产工具(`subagent`、`subagent_fork`)本来就落入 `other`。这些分支只有在工具拒绝自行呈现调用时才在生产中可达:`presentCall` 抛出异常(容错回退),或模型参数未通过工具 schema 导致 `defineTool` 的 `presentCall` 包装层返回 `undefined`(例如 `bash` 调用缺少必需的 `description`)。而桥接层自身的模块文档明确声明了该启发式所违反的设计规则:"桥接层绝不对工具名做特殊处理"。 ## 决策 diff --git a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml index 3644236c38..fa6148d1e6 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-drop-unconsumed-skill-provider-events.md: 90157c03e5df05c98b992ce1dbefea26f4865ce7 -2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 19fec4b827b89b4127b749a9c77715baf39dd00a +2026-07-12-drop-unconsumed-skill-provider-events.md: b0ed7585882328b6abdcf57974200053d9c26048 +2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 557e7ee2155969530a109280d9324b2525e3144a diff --git a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md index 19fec4b827..557e7ee215 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除无消费方的 skill 提供方事件 +# Agent Note: 移除无消费方的 skill 提供方事件 Status: implemented @@ -16,7 +16,7 @@ skill 发现按需读取当前的提供方映射表,提供方注册时同步 skill 注册表不再声明和 emit 提供方成员变更事件。提供方的注册与 dispose(资源释放)仍为 effect 所有的直接状态变更,同步使已完成的 catalog 失效;查找与发现按需读取当前提供方映射表。测试通过提供方查找和收集到的输出来观察清理行为,而非依赖生命周期通知。 -生成的事件 catalog、API catalog 与生产者/消费方矩阵不再包含已删除的通知。skill 系统 RFC 与包文档通过 effect 所有的直接状态及缓存失效契约来描述注册行为。 +生成式事件目录、API 目录和生产者/消费者矩阵均不再包含已删除通知。skill system Agent Note(agent 决策记录)和包文档改为通过其由 effect 直接拥有的状态与 cache 失效契约描述注册。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml index 703539b152..1f8a055362 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-prune-unused-web-seam-fields.md: 9fd05da282c22d78dc98e232ef2c23cd6e9c4ea3 -2026-07-12-prune-unused-web-seam-fields.zh.md: 650b6b74c808c719bcea9783c60064936427ffee +2026-07-12-prune-unused-web-seam-fields.md: c50bf44161579a44b09113fc501f3d67fb5d6855 +2026-07-12-prune-unused-web-seam-fields.zh.md: 401bdd0c812175cffc572e722141d2829a3fc2d5 diff --git a/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md b/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md index 650b6b74c8..401bdd0c81 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md @@ -1,4 +1,4 @@ -# RFC: 裁剪 web seam 中未使用的字段 +# Agent Note: 裁剪 web seam 中未使用的字段 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -web 能力携带的 request/result/status 值,虽然每个已交付的实现都会填充,但没有任何生产环境的消费方读取它们。`WebSearchResult.providerId`、`query`与 `WebFetchResult.providerId` 是结果回显;`tool-web` 只格式化 content/sources/truncation 或最终 URL/status/body/truncation,没有其他运行时读取这些字段。搜索提供方返回 `WebProviderStatus.reason`,但可用性检查只看 `available`,并有意输出一条通用的不可用诊断信息。 +web 能力携带的 request/result/status 值,虽然每个已交付的实现都会填充,但没有任何生产环境的消费方读取它们。`WebSearchResult.providerId`、`query` 与 `WebFetchResult.providerId` 是结果回显;`tool-web` 只格式化 content/sources/truncation 或最终 URL/status/body/truncation,没有其他运行时读取这些字段。搜索提供方返回 `WebProviderStatus.reason`,但可用性检查只看 `available`,并有意输出一条通用的不可用诊断信息。 `WebFetchRequest.timeoutMs` 同样从未被生产调用方设置。`tool-web` 只提供 URL,使用工具定义的 timeout 加 `exec.signal` 作为调用方截止时间,并依赖本地提供方的配置默认值作为兜底。这个未使用的逐请求覆盖迫使 `web-fetch-local` 暴露 `maxTimeoutMs`、对两个 timeout 来源做 clamp,并为没有任何产品路径能选中的优先级规则编写文档和测试。`WebExecContext` 则是另一个单字段包装层:每个调用方分配 `{ signal }`,每个提供方立即解包 `exec?.signal`;不存在第二个执行控制字段。 diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml index ff2ae238ba..50343fef96 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-simplify-session-log-representation.md: dd8e7f319098bcdca9a844f5665583a3aa25ae80 -2026-07-12-simplify-session-log-representation.zh.md: c759b87bbb13903744a8f6bbb139ab71e1c0f39b +2026-07-12-simplify-session-log-representation.md: a40f4013a97a9c940012dbb37d59beb2faf8fb22 +2026-07-12-simplify-session-log-representation.zh.md: a880a74cf41ca7d06f2278792e1abb4ea46ea969 diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md index 97a89e5be7..a40f4013a9 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-12-simplify-session-log-representation.zh.md) + ## Problem The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas. diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md index c759b87bbb..a880a74cf4 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md @@ -1,6 +1,6 @@ -# RFC: 简化会话日志表示 +# Agent Note: 简化会话日志表示 -Status: proposed +Status: implemented [English](2026-07-12-simplify-session-log-representation.md) | 中文 @@ -8,31 +8,28 @@ Status: proposed 会话日志维护着两种表示,其机制复杂度超出了消费方的实际需求:一个伪链表 surface 和自定义的请求头增量。 -`SurfaceManager` 用一个数组、一个 seq 映射和可变的 `prev`/`next` 链接存储相同的顺序。生产代码从不读取 `prev`;压缩(compaction)唯一的 `next` 读取是取数组位置的后继。替换操作已经使用 `indexOf`,因此链接并未让其主要操作达到常数时间。一个 seq 数组加线性替换查找具有相同的渐近替换开销,且只有一种表示需要验证。 +`SurfaceManager` 同时在数组、seq map 和可变 `prev`/`next` 链接中存储相同顺序。生产代码从不读取任一链接:compact 的工具配对 balance 根据按 surface 顺序缓存的每个切点 balance 作答。替换已经使用 `indexOf`,因此链接并未使其主导操作成为常数时间。使用线性替换查找的 seq 数组具有相同的渐近替换成本,却只有一种表示需要验证。 请求头子系统实现了一套自定义的 system/tool 增量编解码器和传输决策层,尽管其契约声明增量只是编码优化,而非可重建性要求。在每个 agent loop(智能体循环)实例边界保留初始/恢复的完整快照,然后在该实例的组装头发生变化时写入一条规范的完整 `request/header`,即可保留回放能力,同时删除 `SystemDelta`、`ToolsDelta`、往返回退逻辑以及持久化的 `request/header-delta` 变体。编解码器专属的词汇随编解码器一起消失,并非因为其各分支本身无效。 -本提案有意保留追加和替换的 `sourceEventSeqs`、崩溃恢复来源信息以及所有 `SessionStartSource` 变体:已实施的 RFC 赋予这些字段审计/拦截角色,零当前读者这一事实不足以推翻它们。 +实现保留追加与替换 `sourceEventSeqs`、崩溃修复 provenance,以及所有 `SessionStartSource` 变体,因为这些字段承担审计/拦截职责,当前没有读取方并不能推翻这一点。 -## 提案 +## 决策 -将 `SurfaceManager.nodes` 改为事件序列号的 `readonly number[]`,移除公开的 `SurfaceNode` 形状。保留内部的替换代信号;更新 tool 配对平衡和压缩调用方,使其通过数组值/索引获取前驱、后继和替换范围,移除节点链接和 seq-to-node 映射。用规范的完整变更头快照替代锚点后的头增量,移除增量编解码器/事件/测试;初始和恢复锚点即使折叠后的头未变也仍为完整快照。 +`SurfaceManager.nodes` 是由事件序号组成的 `readonly number[]`;公共 `SurfaceNode` 形状、node 链接和 seq-to-node map 均已移除。内部替换 generation 信号保留。session-query 使用的完整 `foldSurface()` 读取会返回相同的数字数组表示和替换元数据,而无需让增量 manager 保留历史。工具配对 balance 和压缩使用事件序号与 surface 位置;由 compact 拥有的每切点 balance cache 不依赖 node 链接。 -修订 session-surface 和 reconstructable-request RFC 中描述已移除编码的部分。更新事件类型/不变式、请求日志/回放、持久化 fixture(测试前置数据)、生成的 catalog、包文档和快照。将编解码器专属的 `fallback` 原因替换为锚点后完整快照的显式 `change` 原因,使其与保留的 `initial` 和 `resume` 锚点区分开来。 +请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。 -`SESSION_FORMAT_VERSION` 有意保持在 `0`,因此一份包含 `request/header-delta` 的旧 v0 日志在增量折叠被删除后,本会通过版本检查并静默丢失头变更。seed/load 校验必须在格式边界处拒绝该遗留事件并显式报错;不添加兼容性折叠或迁移。 +`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一响亮失败边界;ACP(Agent Client Protocol)快照 harness 则把合法的 session 中途变更表示为完整固定请求头和完整可读 prompt。 ## 曾考虑的替代方案 **保留链表节点和紧凑增量以备未来扩展。** 链接可能有助于未来的游标 API,增量在大型工具 schema 仅有少量变化时可以缩减日志。但没有已发布的游标使用这些链接,而完整快照以磁盘空间换取了显著更简单的正确性。如果头部体积确实成为问题,可以基于真实 trace 设计压缩方案或经过度量的规范增量方案。 -## 验收标准 +## 验证 -- `SurfaceManager.nodes` 是一个有序 seq 数组,没有 `SurfaceNode`、链接字段或 seq-to-node 映射;增量追加处理和内部替换代信号保留。 -- 回放完整变更头快照能重建出完全相同的请求;不再存在任何 header-delta 事件/类型/编解码器。 -- 包含遗留 `request/header-delta` 的 v0 seed 或持久化日志在回放前被拒绝,JSONL 和 SQLite 加载路径均有覆盖率。 -- 新形状的 v0 JSONL/SQLite 回放、来源信息、崩溃恢复、压缩、快照、不变式、类型检查、覆盖率、doc-sync 和 hygiene 全部通过。 +单元覆盖率固定有序 surface 的追加/替换行为、工具配对、压缩、完整请求头 fold/记录、请求重建和开发不变量。Seed 验证以及 JSONL、SQLite 加载测试会在重放前拒绝旧事件。无密钥 ACP 套件以新形状覆盖记录、刷新、重放、变化请求头固定,以及 sandbox 模式切换 fixture(测试前置数据)。 -## 风险 +## 后果 -完整头会增加日志体积,线性替换查找在非常大的 surface 上可能更慢。替换操作已经是线性的,因为实现调用了 `indexOf`;只有当真实 trace 表明更简单的数组成为瓶颈时才应添加基准测试。由于格式版本保持为 `0`,如果遗漏了对遗留事件的显式拒绝,后果将是静默数据损坏而非类型错误;因此显式报错的加载测试是本提案的组成部分,而非可选的清理工作。 +完整请求头会增加日志体积,线性替换查找在极大 surface 上也可能较慢。由于先前实现调用 `indexOf`,替换原本就是线性的;benchmark 推迟到真实 trace 表明更简单的数组成为瓶颈时再进行。格式版本仍为 `0`,因此显式拒绝旧事件是预发布格式边界的永久组成部分。作为交换,surface 顺序和请求头状态现在各自只有一种表示,删除了链接维护、map、codec 分支、往返 fallback 和感知 delta 的快照规范化。 diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml index 22d74d7424..6b78fa480b 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-property-based-testing.md: 153584d3a2b77c8f2d103db02646f18a9d424b57 -2026-06-11-property-based-testing.zh.md: e11d11f7db9eee97ab81bc678afab5d0fea36bf9 +2026-06-11-property-based-testing.md: ac35591cb76c1d4243ecba153e8e143290716926 +2026-06-11-property-based-testing.zh.md: 4a0def2d28ef828fd68b78e4c1e100ce7f85d4f0 diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md index e11d11f7db..4a0def2d28 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md @@ -1,4 +1,4 @@ -# RFC: 对协议形态代码进行基于属性的测试 +# Agent Note: 对协议形态代码进行基于属性的测试 Status: implemented @@ -22,8 +22,8 @@ Status: implemented ## 后果 - 生成器质量是价值杠杆——生成器偏向小索引池和短字符串,使碰撞与交错频繁发生。 -- **已经产出回报:** BlockAssembler 流测试发现了一个真实 bug——同一索引的重复 `block-end` 覆盖了已刷出的块,导致流式前缀与最终 `blocks()` 不一致。已修复(首次关闭生效,与既有的滞后分片规则一致),并附带一个专门的回归测试。 +- **它已经带来回报:** BlockAssembler 流发现了一个真实 bug——同一索引处重复的 `block-end` 会改写已经完成的块。现已修复(首次关闭优先,与现有迟到项规则一致),并加入专用回归测试。 - 属性测试因超时而 flake 是一个发现,不应通过重试消除。循环属性测试在设计上是确定性的(通过 `agent/status` settle),因此挂起即为真实缺陷。 - 属性测试是对示例测试的补充而非替代;示例测试固定特定分支,服务于 100% 覆盖率门禁。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 10573d8e31..c068706aeb 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-19-acp-snapshot-tests.md: c336b4864b73b8db29c0a8bb983d974348a9515a -2026-06-19-acp-snapshot-tests.zh.md: bc9488562b0698b172ccffff74815a893acf722d +2026-06-19-acp-snapshot-tests.md: 43900632c4e5e3904c2d0e3b75f2a3fe3b3a50cc +2026-06-19-acp-snapshot-tests.zh.md: 9d9e49cf799ccc07fb9fbc4b13261b7d305d68dc diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index bc9488562b..9d9e49cf79 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -1,4 +1,4 @@ -# RFC: ACP 快照测试——一次录制 / 确定性回放 +# Agent Note: ACP 快照测试——一次录制 / 确定性回放 Status: implemented @@ -6,19 +6,21 @@ Status: implemented ## 问题 -单元测试无法覆盖完整的 ACP(Agent Client Protocol)子进程 transcript(文本记录),而真实 API 测试既不确定又需要密钥。因此,面向编辑器的 `session/update` 输出可能在单元覆盖率全绿的情况下发生回归,正如 [default-export 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)所揭示的那样。 +单元测试不会覆盖完整的 ACP(Agent Client Protocol)子进程 transcript(文本记录),而真实 API 测试不具确定性且受密钥门控。因此,即使单元覆盖率为绿色,面向编辑器的 `session/update` 输出仍可能回归,[默认导出事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)已经证明了这一点。 -全 transcript 测试的阻塞因素在于模型:agent 的输出由非确定性的 LLM(大语言模型)驱动,而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度与 fixture(测试前置数据)的确定性兼得。 +全 transcript 测试的阻塞因素在于模型:agent(智能体)的输出由非确定性的 LLM(大语言模型)驱动,而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度与 fixture(测试前置数据)的确定性兼得。 -本 RFC 记录了新增第三层测试——**快照测试**——的决策,以及使其具备确定性、CI 中无需密钥、维护成本低的设计选择。 +本 Agent Note(agent 决策记录)记下了新增第三层测试——**快照测试**——的决策,以及让它具备确定性、在 CI 中无需密钥、且维护成本低廉的设计选择。 ## 决策 -快照测试启动真实的 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将归一化后的输出与已提交的 golden 文件比对。一次从真实 API 录制的会话日志为后续所有模型流提供数据。fixture 就是产品正常持久化的 JSONL。 +快照测试会启动真实 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将规范化输出与已提交的预期输出比较。从真实 API 一次记录的 session log 为后续所有模型流提供数据。fixture 就是产品普通的持久化 JSONL。 ### fixture 即持久化的会话 JSONL -每个场景的 `session.jsonl` 从一次真实运行中采集。`assistant/chunk` 事件重现模型流;tool、message 和 boundary 事件捕获 harness 行为。一份普通的会话产物因此同时充当回放源和行为 golden。 +每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通 session 产物同时充当重放来源和行为预期输出。 + +当场景固定另一种物理存储布局时,其 fixture 会从真实的未打包对应项机械派生。场景测试要求包含每一种预期存储行类型,并在解码后逐事件精确相等;随后,普通重放与日志比较才会证明组合后的进程能够消费并复现该布局。 ### 回放从日志推导模型脚本 @@ -30,7 +32,7 @@ Status: implemented ``` { kind: 'chunks', chunks: StreamChunk[] } -| { kind: 'throw', chunks: StreamChunk[], message: string, code: string, status?: number } +| { kind: 'throw', chunks: StreamChunk[], message: string, code: string } | { kind: 'hang' } ``` @@ -42,24 +44,24 @@ Status: implemented ### 录制采集日志;无密钥回放需要无提供方的配置 -录制使用真实的 `llm-deepseek` 适配器和 JSONL 持久化后端运行场景,然后将产出的 `.jsonl` 复制到场景目录。逐事件追加是持久的,但 harness 在采集前会优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),确保最终事件已刷盘。`llm-replay` 本身不做录制,它只负责回放。 +记录模式使用真实 `llm-deepseek` adapter 和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。 -回放使用 `cordis.snapshot.yml` 覆盖配置,将真实适配器替换为 `llm-replay`,同时保留活跃的组合。录制使用普通配置和 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此一个意外存在的 API key 不会触发真实调用。见[单源配置 RFC](2026-07-04-single-source-acp-replay-config.md)。 +重放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实 adapter,同时保留实时组合。记录使用普通配置和由 harness 提供的持久化根目录。重放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发实时调用。参见[单一来源配置 Agent Note](2026-07-04-single-source-acp-replay-config.md)。 ### 两个表面:归一化后比对 快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的: -1. **stdout transcript**——编辑器看到的带帧 `session/update` JSON-RPC。捕获 ACP bridge 事件→update 转换(`streamSessionEventUpdate`)中的回归。与已提交的 `stdout.golden.jsonl` 比对。 -2. **重新持久化的会话 JSONL**,归一化后与 `session.jsonl` 比对。同一份 fixture 既是回放源也是预期日志。提示词文本被擦除;每个 header 类别一个场景固定可读的 prompt 和 tool 内容,见 [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)。覆盖场景的模型行为完全来自其伴随文件。 +1. **stdout transcript**——编辑器看到的、经过 framing 的 `session/update` JSON-RPC。用于捕获 ACP bridge 中事件→更新转换(`streamSessionEventUpdate`)的回归。与已提交的 `stdout.expected.jsonl` 比较。 +2. **重新持久化的 session JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。Prompt 文本会被清理;按照[请求头固定 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读 prompt 与工具内容。Override 场景仅从其 sidecar 派生模型行为。 两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的 loop、tool 和 boundary 结构。 -归一化替换 session、cwd、protocol-id、时间戳、路径和进程相关的易变值,同时保留确定性序列号。场景将真实 bash 使用限制在稳定命令范围内。stdout golden 保持协议格式(wire format)的 JSONL,每一行原始数据必须可解析为 JSON。Vitest 只更新 stdout golden;归一化后的会话相等性检查从不覆盖回放 fixture。 +规范化会替换 session、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化 session 相等性检查从不覆盖重放 fixture。 ### 隔离:当前靠归一化,后续可加沙箱 -工具的确定性来自临时 cwd、擦除的环境变量、全新的非登录 shell、受限命令和归一化。它不声称具备操作系统级隔离。如果需要更强的隔离层级,可通过既有的[能力 seam](../architecture/2026-06-13-capability-seams.md) 将沙箱执行器替换本地后端。 +工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,sandbox executor 可以通过现有[能力接缝](../architecture/2026-06-13-capability-seams.md)替换本地后端。 ### 回放插件是独立的包 @@ -67,16 +69,16 @@ Status: implemented ### 两个子命令,回放在默认门禁中 -`pnpm run test:snapshot` 无需密钥地回放已提交的 fixture;`test:snapshot:record` 使用真实 API 并重写采集到的会话日志和 stdout golden。fixture 缺失时立即报错。每个场景携带 `input.json`、`stdout.golden.jsonl` 和 `session.jsonl`;无模型场景使用仅含 header 的日志。`replay.override.json` 仅在标记为 `overridden` 的场景中必需,因为它的存在会替换推导出的回放。fixture 守卫拒绝缺失、不匹配和遗留的文件。两个命令均接受场景过滤器。 +`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的 session log 与 stdout 预期输出。缺少 fixture 时会响亮失败。每个场景都包含 `input.json`、`stdout.expected.jsonl` 和 `session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。 ## 曾考虑的替代方案 -- **手工编写的模型 chunk `llm.json`**:早期草案的做法。复用真实会话日志使 fixture 成为系统的真实产物而非手工构建的 mock,并兼作行为 golden。 +- **手工编写包含模型 chunk 的 `llm.json`**——早期草案;复用真实 session log,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 - **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 - **从 `turn/end {kind:'error'|'aborted'}` 合成 throw/cancel 条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 ## 后果 -新测试层为每个场景增加了经评审的 input、session、stdout、可选 override 和可选 workspace fixture。workspace 种子在录制和回放时都会被复制到临时 cwd。作为回报,该层通过真实的 Loader 和 tool 组合提供确定性的无密钥 transcript 覆盖。子进程、input、workspace、归一化和回放 harness 可以支持 ACP 之外的示例。 +新测试层为每个场景增加经过评审的输入、session、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、输入、workspace、规范化和重放 harness 也可以支持 ACP 之外的示例。 -本 RFC 与[拟议的确定性 RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) 相关但不取代它:该提案的「通用回放 fixture」在每次测试后重新推导会话的*消息历史*(一项内部一致性不变式),而快照测试固定的是*外部协议输出*。二者互补:一个守护事件溯源不变式,另一个守护面向编辑器的契约。 +本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生 session *消息历史*(内部一致性不变量),而快照测试固定*外部协议输出*。两者相互补充——一个守护事件溯源不变量,另一个守护面向编辑器的契约。 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index bb98e02fd7..4a14127a76 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-19-real-api-e2e-ci.md: cc3e14e2d411dfa4cc68132f649f4ed26ab1de1d -2026-06-19-real-api-e2e-ci.zh.md: 58a2a87541fd5b73b8272aa729f9dd09a429e4b4 +2026-06-19-real-api-e2e-ci.md: a9289980ada8ab6390b5daa488f07ec258db1bd5 +2026-06-19-real-api-e2e-ci.zh.md: 2e8f9fff5cdbada2b1ebfd45c4b3ebbc10630f09 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 58a2a87541..2e8f9fff5c 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -1,4 +1,4 @@ -# RFC: 在 CI 中对外部 DeepSeek API 运行真实 API e2e 测试 +# Agent Note: 在 CI 中对外部 DeepSeek API 运行真实 API e2e 测试 Status: implemented @@ -6,11 +6,11 @@ Status: implemented ## 问题 -按照策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../testing.md) 论证了无密钥套件只能验证管道连通性而非产品本身,[ACP inject 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)是现成的证据——178 个无密钥测试全绿,而真实编辑器会话一启动就崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)正是为弥合这一差距而存在的:它驱动 agent(智能体)对接实时 DeepSeek API——真实模型调用、真实 bash 工具、多轮次对话、恢复、ACP-over-stdio。 +根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACP(Agent Client Protocol)inject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实编辑器 session 却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多 turn、恢复、ACP-over-stdio。 默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。 -本 RFC 记录的决策是:添加一个**第二个、消费 secret 的工作流**来在 CI 中运行真实 API 套件。由于这是向一个未来可能公开的仓库引入首个 CI secret,属于安全/隔离决策,本文同时记录其依赖的威胁模型以及仓库公开后的变化。 +本 Agent Note(agent 决策记录)记下了新增**第二条消费 secret 的工作流**以在 CI 中运行真实 API 套件的决策;由于向未来可能公开的仓库引入第一个 CI secret 属于安全/隔离决策,本文也记录其依赖的威胁模型,以及仓库公开时需要做出的变更。 ## 决策 @@ -22,7 +22,7 @@ ci.yml 的价值在于它无密钥、可 fork、始终为绿:任何贡献者 ### 约束不是成本,而是可靠性 -内部推理(inference)成本不是限制因素,因此工作流以覆盖率和信号为优化目标。它在多个触发条件和每个可信 PR(Pull Request)上运行所有匹配的 `*.e2e.ts` 文件,落实 [docs/testing.md](../../../testing.md) 的有密钥策略。 +内部推理成本不是限制因素,因此工作流针对覆盖面和信号优化。它会在多种触发条件和每个受信任 PR(Pull Request)上运行所有匹配的 `*.e2e.ts` 文件,以落实 [docs/testing.md](../../../../docs/testing.md) 的有密钥策略。 ### 触发条件:仅限可信事件 @@ -58,6 +58,8 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试 job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和 schedule 运行完整执行以提供合并后信号。 +DeepSeek 原生 `web_search` 探测已注册但会跳过。实时 Anthropic 兼容端点可能返回成功响应却没有结构化来源块,因此对来源存在性的正向断言不是可靠的合并信号;单元覆盖率仍会固定响应解析,但 CI 不会证明实时来源块的线协议形状。 + ## 安全性 仓库的首个 CI secret 需要一份记录在案的威胁模型,因为同仓库 PR、fork PR 和 Dependabot PR 的访问权限各不相同,且仓库公开后会发生变化。 @@ -95,6 +97,6 @@ job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属 新增一个 CI 工作流和仓库的首个需要维护的 secret。真实 API 套件现在作为合并门禁(可信 PR 上的合并前门禁、主分支上的合并后门禁)并每夜运行,因此 agent 与外部 API 交互中的真实故障会在 CI 中浮现,而非仅在开发者的本地运行中出现——代价是每个可信 PR 和合并都会产生真实的(但内部免费的)API 调用。preflight 使 secret 配置错误变为自我通告而非静默禁用安全网。 -本设计携带一个记录在案的约束面:`pull_request` 触发器的密钥暴露权衡(移除以加固)、`if:` 门禁对基于作者的 Dependabot 判断的依赖,以及对 `pull_request_target` 的硬性禁止。上述公开清单是运维伴侣——本 RFC 是未来维护者在更改触发器集合或翻转仓库可见性之前应重读的地方,而非从头重新推导 fork/secret 模型。 +该设计带有已记录的约束表面:`pull_request` 触发器在密钥暴露方面的取舍(删除它可加强防护)、`if:` 门禁对基于作者的 Dependabot 检查的依赖,以及对 `pull_request_target` 的严格禁止。上方公开仓库检查清单是操作配套——未来维护者在更改触发器集合或切换仓库可见性之前,应重新阅读本 Agent Note,而不是从头推导 fork/secret 模型。 schedule 触发器在仓库不活跃 60 天后会自动禁用(GitHub 行为);push/PR/dispatch 是后备,活跃的 monorepo 不会触及此限制。假设 runner 对 `https://api.deepseek.com` 有出站连通性——GitHub 托管的 `ubuntu-latest` 具备此条件;受出站限制的自托管 runner 需要在依赖每夜运行之前确认连通性。 diff --git a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml index 5f59fbacf1..e01a578a83 100644 --- a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-remove-redundant-snapshot-log-goldens.md: 18d0a4491eb10a3b4dc56d3d63285c219ba6a00a -2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: 5675c69862b6052ed3f3e4710461cc1478b9fa7d +2026-06-20-remove-redundant-snapshot-log-expected-output.md: c2452f971d3cb76dceb766072dbc0a5c81465e78 +2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md: b584bfa0154c71c1bbb683c3041dc7baf7a3548e diff --git a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md index b17ecad098..c2452f971d 100644 --- a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md +++ b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md) + ## Problem Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.expected.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.expected.jsonl`. In the current fixtures, the two normalized logs are identical for ordinary recorded scenarios. diff --git a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md index 5675c69862..b584bfa015 100644 --- a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md +++ b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md @@ -1,24 +1,24 @@ -# RFC: 使用 `session.jsonl` 作为唯一的快照会话日志产物 +# Agent Note: 使用 `session.jsonl` 作为唯一的快照会话日志产物 Status: implemented -[English](2026-06-20-remove-redundant-snapshot-log-goldens.md) | 中文 +[English](2026-06-20-remove-redundant-snapshot-log-expected-output.md) | 中文 ## 问题 -模型驱动的 ACP(Agent Client Protocol)快照场景同时包含 `session.jsonl` 和 `session.golden.jsonl`。对于普通录制场景,`session.jsonl` 是从真实运行中采集的回放 fixture(测试前置数据),回放测试对新持久化的日志做归一化后与 `session.golden.jsonl` 比较。在当前 fixture 中,普通录制场景的归一化录制日志与归一化 golden 完全一致。 +驱动模型的 ACP(Agent Client Protocol)快照场景同时包含 `session.jsonl` 和 `session.expected.jsonl`。对于普通记录场景,`session.jsonl` 是从真实运行采集的重放 fixture(测试前置数据);重放测试会规范化新持久化的日志,并将其与 `session.expected.jsonl` 比较。在当前 fixture 中,普通记录场景的两份规范化日志完全相同。 -手工编写的覆盖场景(`error-finish`、`cancel`)目前使用 `replay.override.json` 驱动模型行为,并保留 `session.jsonl` 作为最小占位 fixture,而 `session.golden.jsonl` 存放预期的持久化日志。覆盖文件是一个 `ReplayEntry` 对象的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }`、`{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }` 或 `{ "kind": "hang" }`。这种拆分同样是多余的:当覆盖 sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 获取模型分片,因此 `session.jsonl` 仍可作为该场景的预期会话日志产物。 +手工编写的 override 场景(`error-finish`、`cancel`)目前使用 `replay.override.json` 驱动模型行为,并把 `session.jsonl` 保留为最小 dummy fixture,而 `session.expected.jsonl` 存放预期的持久化日志。override 文件是由 `ReplayEntry` 对象组成的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }`、`{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }` 或 `{ "kind": "hang" }`。这种拆分同样没有必要:override sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 取得模型 chunk,因此 `session.jsonl` 仍可作为场景的预期 session log 产物。 ## 决策 -彻底移除 `session.golden.jsonl` 概念。每个场景最多只有一个提交到仓库的会话日志产物,即 `session.jsonl`: +彻底移除 `session.expected.jsonl` 概念。每个场景最多只有一个已提交 session log 产物,即 `session.jsonl`: - 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行归一化后的持久化日志与归一化后的 `session.jsonl` 进行比较。 - 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时,回放适配器不从 fixture 获取模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。 - 对于无模型场景,`session.jsonl` 可保留为引导 `llm-replay` 所需的最小 fixture;除非场景创建了持久化会话,否则无需进行会话日志比较。 -stdout golden 保持不变;它们是面向编辑器的投影,与会话 fixture 不构成冗余。 +Stdout 预期输出保持不变;它们是面向编辑器的投影,与 session fixture 并不重复。 ## 曾考虑的替代方案 @@ -26,11 +26,11 @@ stdout golden 保持不变;它们是面向编辑器的投影,与会话 fixtu ## 验证 -`session.golden.jsonl` 在快照 harness、fixture、遗留文件守卫和文档中均不再出现;快照测试对每个模型场景都从 `session.jsonl` 派生预期会话日志;手工编写的 sidecar 场景将预期产出的日志作为 `session.jsonl` 提交,并以 `replay.override.json` 作为模型行为覆盖;遗留 fixture 守卫知道每种场景类型需要哪些文件。[ACP 快照测试 RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) 描述了精简后的 fixture 集合。 +快照 harness、fixture、孤立项守卫和文档中都不再出现 `session.expected.jsonl`;对于每个模型场景,快照测试都从 `session.jsonl` 派生预期 session log;手工编写 sidecar 的场景把预期生成日志提交为 `session.jsonl`,并以 `replay.override.json` 覆盖模型行为;孤立 fixture 守卫知道每种场景类型所需的文件。[ACP 快照测试 Agent Note(agent 决策记录)](2026-06-19-acp-snapshot-tests.md)描述了精简后的 fixture 集合。 ## 后果 -评审者失去了一个让预期持久化日志在视觉上与回放 fixture 分离的产物名称。stdout golden 仍保护编辑器 transcript(文本记录),将回放输出与 `session.jsonl` 比较则在不重复文件的前提下保留了循环/持久化的回归检查。 +评审者失去了一个能在视觉上区分预期持久化日志与重放 fixture 的产物名。stdout 预期输出仍然保护编辑器 transcript(文本记录),而将重放输出与 `session.jsonl` 比较,无需复制文件即可保留循环/持久化回归检查。 ## 实现说明 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index cfae43ecf0..3ad2200f26 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-fork-child-replay-seed-boundary.md: 28ce76309da2dca7076dd11211229a0631d11db3 -2026-06-22-fork-child-replay-seed-boundary.zh.md: 92b60589b4cfe9668a1542405693cec8d29eceaf +2026-06-22-fork-child-replay-seed-boundary.md: d3cbbb1dae1d64a10973bd5895ccc47d877eba28 +2026-06-22-fork-child-replay-seed-boundary.zh.md: 7137256f0e1f34a2da928b966d170d054f18ddde diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index 92b60589b4..7137256f0e 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -1,4 +1,4 @@ -# RFC: 持久化 seed 边界以确保 fork 子会话回放正确路由 +# Agent Note: 持久化 seed 边界以确保 fork 子会话回放正确路由 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[逐会话快照回放 RFC](2026-06-22-subagent-snapshot-replay.md) 让快照层表达了嵌套 agent(智能体)的形状:一个父会话加上每个进程内 subagent 各一份已录制的日志,各自作为独立脚本回放、以调用方会话为键。该 RFC 指出(§ Scope 末尾条目)fork 快照是「一个平凡的后续补充,不是键控方案的缺口」。这对 fork 子会话而言是错的——问题不在键控,而在*脚本推导*。 +[逐 session 快照重放 Agent Note(agent 决策记录)](2026-06-22-subagent-snapshot-replay.md)使快照层能够表达嵌套 agent 形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用 session 作为键,以独立脚本重放。它曾指出(§ 范围,最后一个项目符号),fork 快照“只是未来很容易添加的一项,并非键控缺口”。这一判断对 fork 子项而言是错误的——问题不在键控,而在*脚本派生*。 subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从已录制的会话日志推导:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自身的模型调用。 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml index e487e40249..fd59b8dff8 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-fork-snapshot-scenarios.md: a5324cbfa13b79c0ea60b74b689f1b19db99a725 -2026-06-22-fork-snapshot-scenarios.zh.md: 543382db86eb50b5f278a99de74586a13bff9eb7 +2026-06-22-fork-snapshot-scenarios.md: 46c688a4095a1d8af32b3b99887929f71a1526ce +2026-06-22-fork-snapshot-scenarios.zh.md: afeb495e480a601656fc551a478e0e40c579cfd5 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md index 543382db86..afeb495e48 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md @@ -1,4 +1,4 @@ -# RFC: 记录 fork 与混合 spawn+fork 快照场景 +# Agent Note: 记录 fork 与混合 spawn+fork 快照场景 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) 使 fork 子会话的回放路由正确运作:`dsh-llm-replay` 从子会话持久化的 `seedLength` 边界处或之后的事件推导出子会话的脚本,因此 fork 子会话继承的父会话前缀不会被当作子会话自身的模型调用来回放。但该 RFC 交付时**没有记录 fork 场景**——该切片仅由 `llm-replay` 的单元测试(一个合成的子会话 fixture(测试前置数据))和一个持久化往返测试覆盖。全 transcript(文本记录)快照层(即启动真实 `acp-agent` 并回放端到端嵌套 transcript 的那张网)只有 spawn 子会话(`subagent-spawn`、`subagent-multi`)。如果一个 fork 路由回归让单元测试保持绿色,它仍然会逃过专为捕获 transcript 回归而建的那一层。 +[seed 边界 Agent Note(agent 决策记录)](2026-06-22-fork-child-replay-seed-boundary.md)让 fork 子项重放能够正确路由:`dsh-llm-replay` 根据持久化 `seedLength` 边界处及其后的事件派生子项脚本,因此 fork 子项继承的父前缀不会作为子项自身的模型调用重放。但落地时**没有记录式 fork 场景**——slice 只由 `llm-replay` 单元测试(合成子项 fixture(测试前置数据))和持久化往返测试覆盖。完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 并重放端到端嵌套 transcript 的那张网——只有 spawn 子项(`subagent-spawn`、`subagent-multi`)。如果 fork 路由回归没有让单元测试变红,它仍会逃过专为捕获 transcript 回归而构建的这一层。 表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都在 `cordis.yml` / `cordis.snapshot.yml` 中以两个面向模型的工具接入(`subagent` → spawn、`subagent_fork` → fork),harness 会收集每个子会话的日志,回放按 `seedLength` 为键转发各子会话的 fixture。缺少的是一个*已记录的场景*来驱动 fork 子会话走完这条路径。 @@ -15,17 +15,17 @@ Status: implemented 针对真实 API 记录两个场景,均在默认门禁中以无密钥方式回放: - **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此可以从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的记录而非手工合成。 -- **`subagent-mixed`**:父会话完成一个轮次,然后在同一个 transcript 中分别通过 `subagent`(全新的 spawn 子会话,`seedLength` 为 0)和 `subagent_fork`(fork 子会话,`seedLength` 非零)各委派一次。这是 seed-boundary 和 per-session-replay 两份 RFC 都列为后续补充的混合 spawn+fork 场景:一个 transcript 同时覆盖两种传输方式和切片的两个分支(`seedLength` 0 = 无操作,`seedLength > 0` = 裁剪继承的前缀),两个子会话按 `createdAt` 排序为先 spawn 后 fork。 +- **`subagent-mixed`**——父项完成一个 turn,随后在同一 transcript 中通过 `subagent` 委托一次(全新 spawn 子项,`seedLength` 为 0),再通过 `subagent_fork` 委托一次(fork 子项,`seedLength` 非零)。这是 seed 边界与逐 session 重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(`seedLength` 为 0 = 无操作,`seedLength > 0` = 裁剪继承前缀),两个子项按 `createdAt` 排列为先 spawn、后 fork。 ### 为什么需要一个已完成的第一轮次 -fork 后端用父会话的**已完成轮次的平衡前缀**([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来初始化子会话。如果父会话在第一轮次就 fork,则没有已完成的轮次可继承,seed 为空(等价于全新 spawn,`seedLength` 为 0),这不会覆盖切片逻辑。因此两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立一个 codeword,子会话稍后被要求回忆它),第二个 prompt 委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带产物;真正承载验证的产物是子会话 fixture 中记录的 `seedLength`,回放切片消费的正是它。 +fork 后端使用父项的**已配平完整 turn 前缀**为子项提供 seed。父项若在第一个 turn 就执行 fork,没有已完成 turn 可供继承,因此 seed 为空(≡ 全新 spawn,`seedLength` 为 0)——这不会覆盖 slice。因此,两个场景都使用双 prompt 输入:第一个 prompt 完成一个 turn(建立稍后要求子项回忆的 codeword),第二个 prompt 委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 `seedLength`。 ## 后果 - fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话记录的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 - `subagent-mixed` 是第一个在同一个 transcript 中驱动两种*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。 -- 进程外(ACP)subagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。 +- 进程外(ACP(Agent Client Protocol))subagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。 - 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在无密钥时自动跳过,与所有已录制场景一致。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index c8d42599e1..479d521c97 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-22-subagent-snapshot-replay.md: 89fc4e8d4d267fd4df373a7fd82b8c6e742be6ea -2026-06-22-subagent-snapshot-replay.zh.md: 7dc234ab9c6e3bb1facd78e98aad15005d158325 +2026-06-22-subagent-snapshot-replay.md: 4aad8b6ddd3e4f8b8ad5565e10b263953d81ea31 +2026-06-22-subagent-snapshot-replay.zh.md: 9d31e4d7f8e65b8442a94dee154ff1d3a5631651 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index 7dc234ab9c..9d31e4d7f8 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -1,4 +1,4 @@ -# RFC: 嵌套 agent 的逐会话快照回放 +# Agent Note: 嵌套 agent 的逐会话快照回放 Status: implemented @@ -6,14 +6,14 @@ Status: implemented ## 问题 -快照测试层(`pnpm run test:snapshot`)启动真实的 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放录制的会话,并将归一化后的 stdout transcript(文本记录)与重新持久化的会话日志对已提交的金标文件做 diff。这是唯一一个端到端验证完整编辑器侧 transcript 的测试层。 +快照层(`pnpm run test:snapshot`)会启动真实 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 重放已记录 session,并将规范化 stdout transcript(文本记录)+ 重新持久化的 session log 与已提交预期输出进行 diff。它是唯一端到端覆盖完整面向编辑器 transcript 的测试层。 该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处: -- **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent 和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent(反之亦然)。 +- **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent(智能体)和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent(反之亦然)。 - **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行,在同一个 cwd bucket 下有自己的日志,因此子 agent 的 transcript 被静默丢弃。 -这就是 [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 中记录的 `TODO(subagent-snapshots)` 延期项:进程内后端(PR2)已有单元测试和 e2e 覆盖,但全 transcript 快照层在本基础设施就绪之前无法表达嵌套 agent 的形态。本 RFC 即为该堆叠后续。 +这就是 [subagent 接缝 Agent Note(agent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端(PR2)落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。 ## 决策 @@ -55,4 +55,4 @@ Status: implemented - `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。 - `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外同样有用(遥测、请求路由)。 - `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。 -- 进程外(ACP)subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 +- 进程外(ACP(Agent Client Protocol))subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml index 8c06af4e98..fd07ac1419 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-hook-snapshot-matrix.md: b365992c01e081e5698e81a9ff9682e9b8166ce6 -2026-07-04-hook-snapshot-matrix.zh.md: bcb8e5ba55a14dd0c299dac161146a19f18201eb +2026-07-04-hook-snapshot-matrix.md: ceb91a70e1f9582cf2a7cb4cec0ba8aaf5699b8e +2026-07-04-hook-snapshot-matrix.zh.md: 0e2318d48dbd0200c04dd35decf8046ed6ecce9f diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md index bcb8e5ba55..0e2318d48d 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md @@ -1,4 +1,4 @@ -# RFC: Hook 快照矩阵——覆盖两种 bridge 的端到端 golden 测试 +# Agent Note: Hook 快照矩阵——覆盖两种 bridge 的端到端 预期输出 测试 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——将外部 hook 命令映射到 harness 的拦截 seam 上。它们拥有深度的单元测试和 coverage-spec 覆盖率(每个决策分支、每种 payload 方言,均对 mock 的 seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但完整 transcript(文本记录)快照层:那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将规范化的 ACP stdout 与重新持久化的日志与已提交 golden 做 diff 的网,只覆盖了一个 hook:Claude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。 +hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——把外部 hook 命令映射到 harness 拦截接缝。它们有深入的单元与覆盖率规格覆盖(每个决策分支、每种 payload dialect,针对 mock 接缝驱动),外加一个受密钥门控的 e2e(`hooks.e2e.ts`,实时 `PreToolUse` 阻止)。但完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 子进程、无需密钥重放已记录 session,并将规范化 ACP(Agent Client Protocol)stdout + 重新持久化日志与已提交预期输出进行 diff 的那张网——只覆盖了一个 hook:Claude `UserPromptSubmit` 阻止(`hook-cc-promptsubmit-block`)。 这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实 hook 进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个 hook 点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex hook 能端到端触发。 @@ -31,20 +31,22 @@ hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) 每个 hook 命令只输出固定字面量字符串(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop hook 会在每一步都 force-continue。 +`PostToolUse` 阻止场景会在其证明的机制处自行限制。Claude hook 在首次拒绝后持久化一个 workspace 标记,因此允许一次恢复调用;Codex prompt 发起一次调用并报告注入结果。每份预期输出固定一次遭阻止调用,不会重复阻止/重试循环。 + ### 三个 hook 点被有意排除在快照之外 在构建矩阵过程中发现,记录于此是因为这些遗漏是决策而非疏忽: -- **`SessionStart` 与 `SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有轮次绑定。由此产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的 golden 甚至无法在自身回放中复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在 bridge 的单元覆盖率中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——它们就可以纳入快照。) -- **`SubagentStop`** 是纯观察性的:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript 不写入任何内容,因此 golden 与无 hook 运行逐字节一致,永远无法被证明失败——一道永远不会触发的守卫。它留在单元覆盖率中(`bridge.spec.ts` 已断言了纯观察调用)。 +- **`SessionStart` 和 `SubagentStart`** 通过脱离且尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有 turn 绑定。由此产生的 `context/message` 会与它应先于的工作(首次模型请求 / 子项的第一个 turn)竞速,并落在不确定的日志位置。记录的预期输出甚至无法在自己的重放中复现——对两者执行 10 次重放稳定性检查,结果均为 10/10 次失败。它们继续留在 bridge 的单元覆盖率中,那里会直接驱动接缝而不存在时序竞速。(如果注入未来改为绑定 turn 且具备确定性——`TODO(session-start-gating)` 所指方向——它们就能接受快照测试。) +- **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递 turn(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无 hook 运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。 因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的 hook 点,涵盖两种方言。 ## 后果 -- 每个具有可观测 transcript 的 bridge seam 映射现在都在完整 transcript 层级、在真实应用中、对两种方言受到守护——包括此前完全没有端到端覆盖率的 Codex bridge。录制的 golden 捕获了模型对 deny/block/force-continue 轮次的真实反应,这是手工编写的 transcript 只能猜测的。 -- block 场景无需密钥(无模型轮次);其余场景从录制的 fixture(测试前置数据)无密钥回放。`pnpm run test:snapshot:record` 从真实 API 重新生成录制的 fixture,无密钥时自动跳过,与所有录制场景一致。 -- prove-red 纪律成立:篡改 hook 配置的输出(例如修改 deny 原因)会使其场景在回放时变红——hook 进程在回放期间真实运行(只有模型被回放),因此 golden 守护的是实际的 hook→seam→loop 路径,而非它的 mock。 +- 现在,两种 dialect 中每个具有可观察 transcript 的 bridge 接缝映射,都在真实应用的完整 transcript 层受到守护——包括此前完全没有端到端覆盖的 Codex bridge。记录的预期输出捕获模型对遭拒绝/遭阻止/强制继续 turn 的真实反应,而手工编写的 transcript 只能猜测这种反应。 +- `UserPromptSubmit` 阻止场景无需密钥即可编写(没有模型 turn);其余场景从已记录 fixture(测试前置数据)无需密钥重放。`pnpm run test:snapshot:record` 从实时 API 重新生成记录式 fixture,并像所有记录场景一样在缺少密钥时自行跳过。 +- 证明会变红的准则仍成立:篡改 hook 配置输出(例如改变拒绝理由)会让相应场景在重放时变红——hook 进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际 hook→接缝→循环路径,而非其 mock。 - `acp-agent` 演示现在加载了一个通常会无操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml b/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml index 733f3a2115..719884e56c 100644 --- a/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-single-source-acp-replay-config.md: 51cbd54d45408df1548c9cc2522b07b5ffaac110 -2026-07-04-single-source-acp-replay-config.zh.md: 2aec0e0e46007be243c0386fc0fca92065ce3c9e +2026-07-04-single-source-acp-replay-config.md: f270d70feca184217503c472c1cb7c536187a249 +2026-07-04-single-source-acp-replay-config.zh.md: 86eb2d3e15c4939d1de3a78150cd0536d46e10f6 diff --git a/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md b/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md index 2aec0e0e46..86eb2d3e15 100644 --- a/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md +++ b/.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md @@ -1,4 +1,4 @@ -# RFC: 将 acp-agent 回放配置改为单一来源 +# Agent Note: 将 acp-agent 回放配置改为单一来源 Status: implemented @@ -6,13 +6,13 @@ Status: implemented ## 问题 -`examples/acp-agent` 曾维护两份手写配置:`cordis.yml`(正式运行树)和 `cordis.snapshot.yml`(逐条镜像前者,仅替换 LLM(大语言模型)后端)。去掉注释后,全部差异只是八行的 `llm-deepseek` 段落换成两行的 `llm-replay` 段落。每次应用结构变更都要改两遍,且没有门禁保障对称性:一旦两份副本漂移,快照层就会悄悄测试一个与实际交付不同的应用——正是快照层本要消除的["单元测试全绿、产品却坏了"这类缺口](../../../postmortem/0001-acp-default-export-drops-inject.md),在上一层被重新引入,唯一的防线是评审者的警觉。 +`examples/acp-agent` 发布了两份手工维护的配置:`cordis.yml`(实时树)和逐条镜像它、只替换 llm 后端的 `cordis.snapshot.yml`——去除注释后,两者的全部差异就是八行 `llm-deepseek` stanza 与两行 `llm-replay` stanza。每次应用形状变化都必须修改两遍,也没有任何机制约束对称性:如果副本发生漂移,快照层会悄然覆盖与已发布应用不同的应用——快照层本就是为了弥合[“单元测试绿色,产品损坏”这类缺口](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md),如今同类缺口在上一层重新出现,只能依靠评审者警惕。 ## 决策 `cordis.snapshot.yml` include 正式配置,通过 id 和 name 禁用指定的 DeepSeek 适配器,并插入回放适配器。其余所有条目因此来自正式运行树。回放时选择 overlay;录制仍然启动 `cordis.yml`,加载守卫允许被有意禁用的条目。 -overlay 依赖一个 vendor 插件的事实,这是有意为之:include 在加载文件时应用 `patches`,其 `refresh()`/`internal/update` 路径重读时不会重新打补丁。这恰好满足一次性回放启动的需要(回放应用不加载 `hmr`,也没有东西在运行中改写配置)。快照套件即为证明:所有场景在 overlay 上原样通过,包括逐字节一致的 golden 文件。 +overlay 有意依赖一项 vendored 插件事实:include 加载文件时会应用 `patches`,而其 `refresh()`/`internal/update` 路径会重新读取但不重新打补丁——这恰好足以满足一次性重放启动(重放应用不加载 `hmr`,运行中也没有内容重写配置)。快照套件就是证明:所有场景都能在 overlay 上原样通过,包括逐字节相同的预期输出。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml index dfb09d507a..0aba628ae2 100644 --- a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-pin-request-header-content-in-one-scenario.md: 0166b459fbb8d883f07fb195bdd5e025d70349de -2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 1ca7df68fc743b919c6769ae8fa40ea16eb3d88a +2026-07-06-pin-request-header-content-in-one-scenario.md: bca6d9eb943e758d68efaf3a76ec367179cd15fd +2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 9637602aca34977bee7c0efd5dc57b848ed93e2c diff --git a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md index 1ca7df68fc..9637602aca 100644 --- a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md +++ b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md @@ -1,4 +1,4 @@ -# RFC: 在单个快照场景中固定请求头内容 +# Agent Note: 在单个快照场景中固定请求头内容 Status: implemented @@ -10,11 +10,11 @@ Status: implemented ## 决策 -每个 header 组合类别恰好有一个场景被标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.golden.md` 以普通 Markdown 存放归一化后的组合提示词,`tool-schemas.golden.json` 以结构化 JSON 存放完整的初始 schema 及后续 schema 变更,而 `session.jsonl` 保留 config、reason 及任何模型可见的前缀,同时将 `header.system` 和 `header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其余所有 JSONL 使用相同的提示词和工具 token,并同样对会话前缀内容做 token 化处理。固定机制实现在 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) 中,其套件工厂强制每个类别只有一个固定场景。 +每种请求头组合类别恰好有一个场景标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.expected.md` 以普通 Markdown 包含规范化的完整 prompt 序列;`tool-schemas.expected.json` 以结构化 JSON 包含对应的完整 schema 序列;`session.jsonl` 保留 config、reason 和所有模型可见前缀,同时将 `header.system` 与 `header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其他每份 JSONL 都使用相同的 prompt 与工具 token,并同样将 session 前缀内容 token 化。固定机制位于 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md),其套件 factory 强制每种类别恰好有一个固定场景。 -纯粹的 `scrubSystemPrompts` 和 `scrubToolSchemas` 归一化器应用于每个存储的会话 fixture(测试前置数据),独立地对初始 header 内容和 header-delta 批量内容做 token 化。`scrubRequestHeaders` 还为非固定场景的会话前缀内容做 token 化,同时保留结构性事实:system-delta 的位置与数量、新增/移除/变更的工具名称、前缀消息数量、字段存在性、config 和 reason。record 与 refresh 的回写操作在写入 JSONL 前应用相应的 scrub,并从归一化后的实时 header 和 delta 重新生成两个 sidecar 文件,因此两条路径都不会把提示词/schema 批量内容重新引入 JSONL,也不会让评审产物变陈旧。 +纯 `scrubSystemPrompts` 和 `scrubToolSchemas` 规范化器会分别将每个已存储完整请求头 token 化。`scrubRequestHeaders` 还会为非固定场景把 session 前缀内容 token 化,同时保留请求头数量、字段存在性、config、reason 和前缀消息数量。记录与刷新写回会在写入 JSONL 前应用适当清理,并根据规范化的实时完整请求头序列重新生成两个 sidecar,因此两条路径都无法把大段 prompt/schema 重新引入 JSONL,也不会留下陈旧的评审产物。 -守卫机制使这一拆分自我强制。在磁盘上:每个 `session*.jsonl` 都是提示词和 schema 两个 scrubber 的不动点;只有非固定 fixture 还必须是完整 header scrub 的不动点;两个 sidecar 文件恰好存在于固定 fixture 旁边,采用规范的换行终止格式;每个类别有且仅有一个固定场景。在运行时:由 parent、spawn 子会话、fork 子会话、初始请求或 resume 产生的每个 `request/header`,在经过易变值归一化后必须与重建的固定内容匹配;固定运行的提示词和 schema delta 也必须与其 sidecar 匹配。如果 header 没有字符串类型的 prompt、没有数组类型的工具列表,或包含未声明的 `request/header-delta`,则立即失败并报错。 +守卫使这种拆分能够自我强制。在磁盘上,每个 `session*.jsonl` 都是 prompt 和 schema 清理器的固定点;只有非固定 fixture(测试前置数据)必须是完整请求头清理的固定点;两个 sidecar 恰好位于固定 fixture 旁,并采用规范、以换行符结尾的格式;每种类别都有一个固定场景。在实时运行中,由父项、spawn 子项、fork 子项、初始请求、恢复或实例内变化产生的每个 `request/header`,都必须在易变值规范化后与重建的类别序列匹配。请求头若没有字符串 prompt、没有数组值工具列表,或超过固定场景声明的变更请求头数量,就会响亮失败。 一个固定场景覆盖整个套件,因为每个会话(parent、spawn 子会话、fork 子会话)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合将来在设计上变为会话相关的(例如受限的 subagent 工具集),那么分歧的形态将获得自己的固定场景。 @@ -24,11 +24,11 @@ Status: implemented - **仅在比较时 scrub,fixture 保持原始内容**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时会整体重写。存储 token 诚实地表明每个 JSONL 没有固定什么。 - **全部 scrub,不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema)的唯一端到端记录。生成的工具目录只孤立地记录每个工具;只有真实 fixture 才能固定组合后的完整集合。 - **将完整固定内容全部保留在 JSONL 中**:消除了套件范围的重复,但提示词和 schema 变更仍然是一行转义文本。Markdown 和结构化 JSON 为每种内容提供其自然的评审格式,同时不削弱重建 header 的断言。 -- **精简会话日志本身(记录内容摘要,将 header 存放在别处)**:违反可重建性契约:产品日志必须逐位重现每个请求([可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md))。header 体积是测试产物的问题,在测试归一化中解决;线上日志不受影响。 +- **收窄 session log 本身(记录内容 digest,把请求头存到其他位置)**——违反可重建性契约:产品日志必须逐 bit 复现每个请求([可重建请求 Agent Note(agent 决策记录)](../architecture/2026-07-05-reconstructable-requests.md))。请求头体积是测试产物问题,应在测试规范化中解决;实时日志保持不变。 ## 验证 -套件针对拆分后的固定内容回放每个场景。单元测试覆盖率涵盖独立 scrubber 和完整 scrubber、两种 sidecar 格式、record/refresh 重新生成、归一化提示词/schema 提取、不动点强制、必需文件对称性、重建 header 一致性以及 delta 拒绝。 +该套件针对拆分后的固定内容重放每个场景。单元覆盖率会覆盖独立与完整清理器、两种完整请求头 sidecar 格式、记录/刷新重新生成、规范化 prompt/schema 提取、固定点强制、必需文件对称性、重建请求头一致性,以及变更请求头数量拒绝。 ## 后果 diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml index e8c8a129b8..7eaf1b2cc5 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-shared-acp-snapshot-package.md: c378222804251761a1b04f59c35799a97a1525f1 -2026-07-08-shared-acp-snapshot-package.zh.md: cda7a578d4643800736ff159a6427d3a0e3e0fae +2026-07-08-shared-acp-snapshot-package.md: 3e5a2b12114d535490a17361128862f6d1c09a73 +2026-07-08-shared-acp-snapshot-package.zh.md: 63daf0bd8b18a5afb44161b1621ab16ff7285ba2 diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index 8d6032d1cc..3e5a2b1211 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-08-shared-acp-snapshot-package.zh.md) + ## Problem The ACP snapshot tier ([snapshot Agent Note](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure expected-output normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout expected-output and log comparisons, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests). diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md index cda7a578d4..63daf0bd8b 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -1,4 +1,4 @@ -# RFC: 将 ACP 快照套件提取为支持包 +# Agent Note: 将 ACP 快照套件提取为支持包 Status: implemented @@ -6,33 +6,35 @@ Status: implemented ## 问题 -ACP 快照层([快照 RFC](2026-06-19-acp-snapshot-tests.md))由位于某个示例测试目录中的三个模块构成:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,收集持久化日志)、`snapshot-normalize.ts`(纯粹的 golden 规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体加 fixture(测试前置数据)守卫(record/replay 模式、stdout-golden 与日志比对、pinned-header 一致性守卫、orphan/required-file/single-pin 元测试)。 +ACP(Agent Client Protocol)快照层([快照 Agent Note(agent 决策记录)](2026-06-19-acp-snapshot-tests.md))由位于一个示例测试目录内的三个模块构建:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,采集持久化日志)、`snapshot-normalize.ts`(纯预期输出规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(记录/重放模式、stdout 预期输出与日志比较、固定请求头一致性守卫、孤立项/必需文件/单一固定项元测试)。 -第二个 ACP 示例只能复制 record、规范化和收集逻辑,而这些逻辑必须保持一致。`examples/` 下的代码也不在包(package)覆盖率门禁范围内,且原始 harness 只能取消权限请求。共享包使这些机制纳入度量,并允许场景脚本化地提供审批答案。 +第二个希望获得快照覆盖的 ACP 示例——直接消费者是 sandbox/approval 组合——只能复制这些模块,恰好分叉了绝不能漂移的逻辑:记录写回、请求头清理、子 session 采集顺序。spawn/client 胶水也在 `acp.e2e.ts`、`hooks.e2e.ts` 和 harness 中重复三份。文件位置决定了测试严格度:逐文件 100% 覆盖率门禁只测量 `packages/*/*/src`,因此这些机制完全未被测量——正是同一种缺口,曾推动 `dsh-llm-replay` 从 `examples/` 移入 [packages/support](../../../../packages/support/README.md)。此外,harness 的 ACP client 硬编码 `requestPermission → cancelled`,因此 approval 往返——sandbox 组合的主打行为——完全无法在快照层表达。 ## 决策 这些机制位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)(`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,依赖自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` overlay([单源 replay 配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在边缘层——库接收的是已解析的 `mode`。 -**`src/harness.ts`** 提供 `runScenario` 及其脚本/结果类型,以 agent 的 bin 和配置路径为参数。权限答案构成一个 FIFO 队列,以稳定的 option kind(而非随机的 option id)为键。缺少答案时取消该请求;不可用的 kind 取消 agent 请求并使场景失败。 +**`src/launcher.ts`**——`launchAcpTestAgent` 拥有通用的未构建进程边界:绝对 tsx loader 解析、`TSX_TSCONFIG_PATH`、隔离的 harness home、stdio 接线、原始字节 stdout tee、stderr 与更新捕获、失败关闭的权限后备、更新 waiter,以及优雅或信号式关闭。快照场景和普通 e2e 套件提供相同的 `AgentUnderTest`(`binScript`、`configPath`、`tsconfigPath`);扮演用户的测试只提供其权限 handler。ACP 与 hook e2e 套件以及 sandbox/approval e2e 套件都使用该 launcher,而不再重新构建 SDK client 边界。 + +**`src/harness.ts`**——`runScenario` 和输入脚本/结果类型在 launcher 之上叠加确定性步骤、临时 workspace、快照环境和持久化日志采集。其 `session/request_permission` handler 消费可选的 `InputScript.permissionAnswers` FIFO 队列,每个条目按选项**类型**进行选择(id 是 agent 生成的随机值,已提交脚本无法预知;类型是 ACP 稳定词汇,会在回答时映射到已提供的 `optionId`);队列不存在或耗尽时回答 `cancelled`,若请求从未提供某种类型则拒绝该次运行——agent 自身收到的回答是 `cancelled`,因此场景 bug 会使 harness 失败,而不会被吸收为 agent 侧拒绝。由此,approval 套件可以根据 `input.json` 确定性地驱动允许/拒绝往返。 **`src/normalize.ts`** 是纯规范化器,按策略不含钩子:当未来某个事件携带新的易变字段(例如审批耗时),共享规范化器在同一个变更中学会它,保持「规范化」的含义只有一个归属,而非各套件各自扩展清洗逻辑。 -**`src/suite.ts`** 提供 `Scenario` 类型与 `defineAcpSnapshotSuite(options)`,注册逐场景比对、record/refresh 的 fixture 回写、header pin 及其实时一致性守卫,以及 fixture 守卫块(无 orphan 场景目录、必需文件齐全、每个 class 恰好一个 pin、每个 JSONL 是 `scrubSystemPrompts` 的不动点、非 pinning fixture 也是 `scrubRequestHeaders` 的不动点)。pinned-header 契约([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件划分:每个 header class 恰好标记一个 `pinsHeader` 场景,其 `system-prompt.golden.md` 与 JSONL 工具列表将组合后的 header 拆分为可评审的产物;一致性守卫将二者与该 class 中每个实时 header 进行比对。纯辅助函数(`childFixturePaths`、`fixtureContext`、`normalizedHeaders`、`normalizedSystemPrompts`、`formatSystemPromptSnapshot`、`headerDeltaCount`)从模块导出,以便直接进行单元覆盖。 +**`src/suite.ts`**——包含 `Scenario` 类型和 `defineAcpSnapshotSuite(options)`,注册各场景比较、记录/刷新 fixture 写回、带实时一致性守卫的请求头固定项,以及 fixture 守卫块(没有孤立场景目录、必需文件存在、每种类别恰好一个固定项、每份 JSONL 都是 `scrubSystemPrompts` 固定点、非固定 fixture 同时也是 `scrubRequestHeaders` 固定点)。刷新会先展开打包的计时信封,再对齐现有易变事件时间,因此在打包与未打包布局之间切换不会移动后续记录;全新的 chunk fragment 数组仍为权威,因为其边界属于重放行为。场景目录中的 `session.jsonl` 加连续的 `session.<n>.jsonl` 同级文件构成有序主项/子项清单,因此场景表可以声明策略而不重复子项数量。固定请求头契约([固定请求头 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每种请求头类别恰好标记一个 `pinsHeader` 场景,其 `system-prompt.expected.md` 和 JSONL 工具列表把组合请求头拆成可评审产物;一致性守卫会将两者与该类别的每个实时请求头比较。固定场景可以声明任何合法的变更请求头数量,其 Markdown 产物记录每个完整的已变 prompt。纯辅助函数(`sessionFixtureNames`、`fixtureContext`、`normalizedHeaders`、`normalizedSystemPrompts`、`formatSystemPromptSnapshot`、`headerChangeCount`)从模块导出,以便直接进行单元覆盖。 ## 曾考虑的替代方案 -- **将模块复制到每个示例中**:正是本 RFC 要防止的 fork。record/守卫逻辑恰恰是必须在各套件间保持逐字节一致的代码,而示例不在覆盖率门禁范围内,因此每份副本也无法被度量。 +- **把模块复制到每个示例中**——这正是本 Agent Note 要防止的分叉:记录/守卫逻辑恰好是必须在各套件间保持逐字节相同的代码,而示例位于覆盖率门禁之外,所以每份副本也都无法测量。 - **在 `examples/` 下建共享模块目录**:代码仍在覆盖率门禁之外,且需要跨示例边界的相对导入,违反包名导入约定;`examples/` 的叶子节点按设计应保持轻薄。 - **`dsh-acp-demo` 的 `/testing` 子路径导出**:将测试基础设施耦合到产品包的对外服务接口与依赖集中;`packages/support/` 的存在正是为了真实但兼容性承诺较低的开发/测试包,`dsh-llm-replay` 是先例,本包与之配套。 - **导出原始测试体函数而非套件工厂**:每个示例将重新拥有 `describe`/`it` 骨架(每套件约 80 行注册样板),却无灵活性收益;工厂使消费方只需一张场景表加一次调用,而导出的纯辅助函数在工厂设计内保留了可单元测试性。 -- **可注入的 ACP `Client` 工厂,而非声明式 `permissionAnswers`**:灵活性最大,但将 SDK 客户端构造泄露给每个消费方,并在正被统一的层面重新引入逐示例漂移;声明式队列使 `input.json` 成为唯一的脚本化界面,且可被 golden 规范化。 +- **使用可注入 ACP `Client` factory 代替声明式 `permissionAnswers`**——灵活性最大,但会把 SDK client 构造泄漏给每个消费者,并恰好在正在统一的层重新引入逐示例漂移;声明式队列让 `input.json` 保持为唯一脚本表面,并与预期输出规范化兼容。 - **泛化到 ACP 之外(传输无关的快照 harness)**:不存在第二种传输方式;harness 端到端都是 ACP 形态(SDK 客户端、JSON-RPC 帧、`session/update` 等待器),推测性的抽象将是一个超前于任何消费方的 seam 拆分。 ## 测试 -提取保留了所有既有 ACP golden 字节。包的 `src/` 通过脚本化的 ACP 子进程达到逐文件 100% 覆盖率:harness 测试覆盖每个步骤操作、两条预期错误分支、权限选择/回退/不可能选项、环境变量转发、workspace 种子注入、收集排序/噪声/回退;suite 测试对已提交的合成 fixture 执行 replay,并对临时副本执行 record,同时覆盖纯辅助函数。两个结构上不可达的守卫保留了有理由的覆盖率排除。fake agent 将 `session/new` 的 cwd 替换到日志中,包括 Darwin 的 `/var` realpath 行为,与真实 bin 一致。 +提取一致性得到机械证明:迁移后,`pnpm run test:snapshot` 的结果与基准提交匹配,`examples/acp-agent/tests/snapshots/` 下没有任何字节变化。包的 `src/` 在门禁单元运行中保持逐文件 100% 语句/分支/函数/行覆盖,并通过脚本化 fake ACP bin(`tests/fixtures/fake-acp-agent.ts`,每个场景由 fixture 旁的 `behavior.json` 编排行为)经过真实 launcher 驱动:`harness.spec.ts` 直接覆盖 launcher 默认值、捕获、更新等待、关闭以及环境/配置变体,随后覆盖每种场景 step 操作、两个 expect-error 分支、权限队列(选择、后备、不可能点击)、workspace seed,以及采集顺序/噪音/后备分支;`suite.spec.ts` 在收集时真实运行 factory——一个针对已提交合成 fixture 的重放套件和一个针对临时副本的记录套件(写回从不触及已提交树;`ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` 会重新引导它)——并包含纯辅助函数的直接用例。fake bin 会把 `session/new` cwd 而非 `process.cwd()` 代入脚本化日志,与真实 bin 请求头携带的内容一致(darwin 会将 `/var/folders/…` realpath 为 `/private/var/folders/…`)。 ## 后果 -新示例只需一张场景表加 fixture 即可获得完整快照层——sandbox 分支从 master 合入后添加自己的套件(自己的 pin 场景、自己的 overlay、通过 `test:snapshot:record` 生成 fixture、通过 `permissionAnswers` 提供审批答案)。代价:`suite.ts` 导入 vitest,因此该包只能在 vitest 运行中导入——这是其他包没有的形态,已在其 README 中声明;每个套件 pin 自己约 8 KB 的 header fixture(真正不同的组合值得拥有自己的 pin;相同的组合会被该套件的一致性守卫捕获);e2e launcher 的重复仍然存在(`TODO(acp-test-harness)`)——当该迁移落地时,harness 即为提取目标。 +新示例通过场景表加 fixture 即可获得完整快照层,普通 ACP e2e 则通过一次 launcher 调用获得同一条经过测试的进程/client 边界。代价是:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入——其他包都没有这种形状,其 README 已说明;每个套件还要固定自己的约 8 KB 请求头 fixture(真正不同的组合理应拥有自己的固定项;相同组合则会被该套件的一致性守卫捕获)。 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index b355adb77d..dd6683aaa3 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-16-typed-event-schemas.md: 93e470218e810c9c9370dd1c7cae5420c93fa7bf -2026-06-16-typed-event-schemas.zh.md: bca4265527507750abe5b8c114f14508cee91cb9 +2026-06-16-typed-event-schemas.md: 97a7d0c3787eb5556696e25a6c8b1bb75642aba9 +2026-06-16-typed-event-schemas.zh.md: f79a607eaa723c00b00e984e1a6983d24d99c127 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index bca4265527..f79a607eaa 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -1,4 +1,4 @@ -# RFC: 事件词汇的运行时 schema(Zod 与 merge-extensible-map 模式之辩) +# Agent Note: 事件词汇的运行时 schema(Zod 与 merge-extensible-map 模式之辩) Status: proposed @@ -6,7 +6,7 @@ Status: proposed ## 问题 -harness 将其核心词汇——内容块、消息来源、结束原因、轮次触发器、轮次结束原因与会话事件——建模为 **merge-extensible map**:一个 TypeScript `interface`(如 `SessionEventMap`、`ContentBlockMap`),插件通过声明合并对其扩展,公开联合类型则以 `Map[keyof Map]` 派生。这是本仓库的通用扩展模式,记录在 [docs/architecture.md](../../../architecture.md) 中("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`"),`defineTool` 的 `InferArgs` DSL 和 `assertNever` 穷举约定都依赖于它。 +harness 将其核心词汇——内容块、消息来源、结束原因、轮次触发器、轮次结束原因与会话事件——建模为 **merge-extensible map**:一个 TypeScript `interface`(如 `SessionEventMap`、`ContentBlockMap`),插件通过声明合并对其扩展,公开联合类型则以 `Map[keyof Map]` 派生。这是本仓库的通用扩展模式,记录在 [docs/architecture.md](../../../../docs/architecture.md) 中(「The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`」),`defineTool` 的 `InferArgs` DSL 和 `assertNever` 穷举约定都依赖于它。 该模式**仅存在于编译期**。类型在运行时消失:没有 schema 对象可供校验传入值、解析不可信输入或在运行时枚举变体。[会话持久化契约](../../implemented/architecture/2026-06-14-session-persistence.md)暴露了两个后果: @@ -15,7 +15,7 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化和插件边界拥有运行时 schema 而非被擦除的类型。 -本 RFC 界定该问题的范围,不提出具体实现。 +本 Agent Note 界定该问题的范围,不提出具体实现。 ## 为什么这不是一个持久化层的改动 @@ -30,16 +30,16 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 - **六个 merge-extensible map**(约 370 行核心类型):`ContentBlockMap`、`MessageSourceMap`、`FinishReasonMap`(位于 `dsh-llm`);`TurnTriggerMap`、`TurnEndReasonMap`、`SessionEventMap`(位于 `dsh-session`)。 - **约 10 处 `declare module` 扩展点**,分布在 `dsh-agent`、`dsh-agent-loop`、`dsh-bash`、`dsh-llm`、`dsh-session`、`dsh-session-persistence`、`dsh-system-prompt`、`dsh-tools` 各包中——每处都将从声明合并改为运行时 `register()` 调用。 - **事件生产者**——agent loop(智能体循环)中 16 处 `session.append(...)` 调用——形状不变,但现在在边界处被校验。 -- **约 7 个 switch 消费方**,对这些联合类型进行分支:`deriveMessages`(`dsh-session`)、`BlockAssembler`(`dsh-llm`)、`dsh-invariants` 插件、两个 LLM(大语言模型)适配器(`dsh-llm-deepseek`、`dsh-llm-pi-ai`)以及工具 schema 层(`dsh-tools`)。`assertNever` 对封闭联合类型的穷举 vs 对可扩展联合类型的 fall-through 约定(一条已记录的 lint 规则)需要重新考量——运行时变体在静态层面不可穷举。 +- **约 7 个 switch 消费方**,对这些联合类型进行分支:`deriveMessages` 与包自有的不变式 companion(`dsh-session`)、`BlockAssembler`(`dsh-llm`)、两个 LLM(大语言模型)适配器(`dsh-llm-deepseek`、`dsh-llm-pi-ai`)以及工具 schema 层(`dsh-tools`)。`assertNever` 对封闭联合类型的穷举 vs 对可扩展联合类型的 fall-through 约定(一条已记录的 lint 规则)需要重新考量——运行时变体在静态层面不可穷举。 - **`defineTool` 的 `InferArgs` DSL**(`dsh-tools`),它从编译期 schema 规范派生出零类型转换的 `execute` 参数类型——这是当前方案的标杆用例。 -- **文档**:architecture.md(该模式被描述为基础性的)、[dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md),以及所有引用该模式的 RFC。 +- **文档**:architecture.md(该模式被描述为基础性的)、[dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md),以及所有引用该模式的 Agent Note。 这是一次仓库级别的词汇重新设计,而非持久化的实现细节。 ## 曾考虑的替代方案 ### A. 维持现状——merge-extensible 类型 + 持久化边界处 `isJsonValue` -保留编译期模式。持久化继续使用不透明 JSON + 可序列化性守卫。插件通过声明合并扩展;事件*形状*的正确性由生产者负责,编译期由 TypeScript 保证,开发模式下由 `dsh-invariants` 插件的结构检查保证。 +保留编译期模式。持久化继续使用不透明 JSON + 可序列化性守卫。插件通过声明合并扩展;事件*形状*的正确性由生产者负责,并由 TypeScript 在编译期保证。启用包自有的不变式 companion 后,它们会检查选定的跨记录关系,但不提供通用运行时形状 schema。 - **优点**:零变动;插件扩展只需一行 `interface` 增补,享有完整类型推断,无需运行时注册仪式;无新运行时依赖;`defineTool` DSL 与 `assertNever` 穷举继续工作。 - **缺点**:持久化边界和插件 seam 处无运行时结构校验;格式错误但仍为合法 JSON 的数据被延迟捕获。 @@ -58,11 +58,11 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 ## 提案 -推迟。如果需要在持久化边界做运行时校验,**方案 B**(对封闭的头部和元数据形状使用 schemastery)是现有约定下的适度步骤。**方案 C** 是一个架构决策,需要自己的实现 RFC,其中包括 Zod 与 schemastery 之间的选择。 +推迟。如果需要在持久化边界做运行时校验,**方案 B**(对封闭的头部和元数据形状使用 schemastery)是现有约定下的适度步骤。**方案 C** 是一个架构决策,需要自己的实现 Agent Note,其中包括 Zod 与 schemastery 之间的选择。 ## 验收标准 -- 方案 C 只能通过自己的实现 RFC 推进,绝不能作为持久化的附带改动。 +- 方案 C 只能通过自己的实现 Agent Note 推进,绝不能作为持久化的附带改动。 - 如果采纳方案 B,封闭的头部/元数据形状(JSONL 的 `isHeaderLine` 守卫及同类)改用 schemastery 校验,替代手写守卫,merge-extensible map 保持不动。 ## 风险 @@ -74,4 +74,4 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 - 如果采用注册表,库选 **schemastery**(已在仓库中,已作为配置 schema 库)还是 **Zod**(生态更丰富,目前仅为传递依赖)?同时维护两个 schema 库本身就是一种成本。 - 能否采用混合方案:保留编译期推断(使 `defineTool` 和插件开发体验不受影响),同时为每个变体添加*可选*的运行时 schema,仅在持久化/协议边界校验,而非每次进程内 append 都校验? -- `dsh-invariants` 插件在开发模式下是否已覆盖了足够多的运行时形状缺口,使得边界校验仅在面对真正不可信输入(重新加载外部修改过的日志)时才有必要? +- `ctx.invariants` 服务启用后是否已覆盖了足够多的运行时形状缺口,使得边界校验仅在面对真正不可信输入(重新加载外部修改过的日志)时才有必要? diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml index 3412d91229..1f6a7ec184 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-pre-tool-input-rewrite.md: 85ece78f3bf188b3b702b1af539256747c1cfab2 -2026-06-30-pre-tool-input-rewrite.zh.md: 6a5c2b52627d21476b96448dc120155eab7f2223 +2026-06-30-pre-tool-input-rewrite.md: 5de605e7edf63046b14a125d53072aab9bb5d37e +2026-06-30-pre-tool-input-rewrite.zh.md: 7105fadd5b42c73b0493443935a9db1e37e525ab diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md index 6a5c2b5262..7105fadd5b 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md @@ -1,4 +1,4 @@ -# RFC: 工具执行前输入重写——一致性设计 +# Agent Note: 工具执行前输入重写——一致性设计 Status: proposed @@ -6,7 +6,7 @@ Status: proposed ## 问题 -[拦截 seam RFC](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 +[拦截 seam Agent Note](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 ## 问题本质:执行前参数的三个读取方 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index e3ec2ad677..5cabc005db 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-claude-code-and-codex-subagent-backends.md: 5585ea30a5ba1b4200f096069a28ccf1c3cef727 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 2a6dd2cdea34cb777df5455f0bc12dc7073ff2a2 +2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 893d6a3cbac5293d6fdf2d6fa4d7ad00f856c61d diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 2a6dd2cdea..893d6a3cba 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -1,4 +1,4 @@ -# RFC: Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派) +# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派) Status: proposed @@ -6,23 +6,23 @@ Status: proposed ## 问题 -为 Claude Code 和 Codex 添加隔离的 subagent 提供方。既有的[命名提供方 seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 和 [ACP 后端](../../implemented/feature/2026-06-22-acp-subagent-backend.md)已确立了进程边界的形状。harness 的一个轮次应能将一个自包含任务委派给上述任一产品,并接收其最终答案,同时不暴露父进程的密钥,也不继承来自 `~/.claude` 或 `~/.codex` 的宿主配置。 +subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP 后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为机械上相似的兄弟。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 ## 提案 两个兄弟提供方包(ACP 后端的结构变体),加一次提取: -- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI 作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——"claude" 保留给未来的 `dsh-llm` 适配器。 +- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI 作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。 - `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 -- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`SENSITIVE_ENV_PATTERN`/`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose 阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 +- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose 阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 -两个提供方遵循 ACP 后端契约:每次 `start` 创建一个全新子进程、一次 prompt 往返、不继承父上下文也不声明可选能力、忽略 `request.parent` 和 `request.agentOptions`、使用随机的品牌化 agent id。`result` 从不 reject;子进程失败映射为 stop reason,原始错误送入 logger。每个提供方以不同的工具名挂载 `dsh-tool-subagent`。工具结果是唯一新增的模型可见产物,因此无需新的会话事件;工作区变更仍是 transcript(文本记录)回放之外的环境副作用。 +两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次 prompt 往返、所有能力均为 `false`、`inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions`、`id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName`(`subagent_claude_code`、`subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。 ## 已验证的接口事实(固定版本) 两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 -**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 RFC 范围内。 +**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。 **codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 @@ -33,11 +33,11 @@ Status: proposed ## 隔离与凭证 -认证方式仅限 API key。每次运行使用一个全新的配置目录(Claude Code 用 `CLAUDE_CONFIG_DIR` 配合 `settingSources: []`,Codex 用 `CODEX_HOME`),dispose 时尽力删除;配置也可以选择一个持久目录。共享的子进程环境辅助函数转发 `PATH`、`HOME`、`TMPDIR`、locale 和代理设置等普通值,移除凭证形态的名称,并叠加显式的 `config.env`。Claude Code 通过该叠加接收 API key,而 Codex 通过 `account/login/start` 接收,而非手写认证文件。 +部署只使用 API key 认证,子进程不得看到宿主用户的 Claude Code / Codex 配置:行为必须只由 `cordis.yml` 决定。每次运行获得一个全新的 `mkdtemp` 配置目录——Claude Code 使用 `CLAUDE_CONFIG_DIR`(并显式设置 `settingSources: []`),Codex 使用 `CODEX_HOME`——dispose 时尽力删除;配置字段也可以固定一个持久目录。子进程环境通过提取逐字复用 ACP 后端的 `buildChildEnv` 语义:转发环境变量,但移除凭证形态的变量(`/KEY|SECRET|TOKEN/i`),再叠加 `config.env`——因此 `PATH`、`HOME`、`TMPDIR`、locale 和代理变量保留,CLI 正常运行;只有环境中的凭证形态变量被清洗(Claude Code 的 `ANTHROPIC_API_KEY` 通过 `config.env` 显式进入),Codex key 则通过 `account/login/start` RPC 进入隔离的 `CODEX_HOME`,而非手写 `auth.json`。 ## 权限与审批策略 -每个后端暴露其引擎原生的策略词汇。Claude Code 默认 `permissionMode: default` 配合 `permission: reject`;Codex 默认 `sandboxMode: read-only`、`approvalPolicy: never`,以及相同的拒绝回退。示例可选择启用 `acceptEdits` 或 `workspace-write`。已知的审批、用户输入和 elicitation 请求接收配置的应答;未知方法接收 method-not-found,未知通知被消费。没有 prompt 到达人类,子进程也不会因等待不可用的输入而无限挂起。 +每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有漏过请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中 prompt 不会到达人类,与 ACP 一致。 ## StopReason 映射 @@ -47,11 +47,11 @@ Claude Code:`success` → `completed`;`error_max_turns`、`error_during_exec ## 测试 -每个适用层级都要求覆盖: +依照根 AGENTS.md 规则在每个层级明确命名,并预先消除风险: -- **无密钥单元/集成测试:** 通过真实 SDK 驱动一个假 Claude CLI,通过真实协议客户端驱动一个脚本化的 Codex app-server。在逐文件 100% 覆盖率下,验证往返、每种 stop 映射、两条取消路径及预中止、权限策略、未知消息、spawn 失败、reload 清理、导出形状、清洗后的环境、临时目录删除,以及 Codex 认证预检失败。 -- **有密钥 e2e 测试:** 每个真实引擎在 `acceptEdits` 或 `workspace-write` 下执行文件操作;跳过时命名缺失的二进制或密钥,并断言无残留子进程。 -- **快照测试:** 标记为 `TODO(claude-code-subagent-replay)` 和 `TODO(codex-subagent-replay)` 推迟,等待 [subagent 回放 RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md) 描述的进程特定回放形状。 +- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR 提供方清理、导出形状、子进程环境隔离断言和临时目录删除;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并讲 plain stream-json,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。 +- **有密钥 e2e 测试**:每个后端的真实引擎执行并由磁盘验证真实文件工作,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`,Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。 +- **快照测试**:以 `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` 推迟——即 ACP 后端也推迟的独立回放形状([按会话回放 Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md));在此期间由无密钥套件提供确定性覆盖。 ## 曾考虑的替代方案 @@ -61,7 +61,7 @@ dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号 ### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)? -Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 RFC,而非针对后端。 +Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 Agent Note,而非针对后端。 ### 为什么不用登录态凭证和用户自身的配置? @@ -73,7 +73,7 @@ Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema ### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端? -社区 shim 将两个引擎包装为 ACP,这会使它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方的第三方层,抹去了本 RFC 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏替换了第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。 +社区 shim 将两个引擎包装为 ACP,这会使它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方的第三方层,抹去了本 Agent Note 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏替换了第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。 ## 验收标准 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml index b7300fc7f3..08f5e8380e 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-interactive-side-sessions.md: ac29f80b31492ce79512cc4d08e33480e0ac6258 -2026-07-08-interactive-side-sessions.zh.md: 5d0ef101dc23febefec881b12fcbb5ba4dcf8be9 +2026-07-08-interactive-side-sessions.md: dfd325babe215782c9c1cbec3fd9f874783af7ab +2026-07-08-interactive-side-sessions.zh.md: d545f65a821c6057b062300f31c415a2d7f63860 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md index 5d0ef101dc..d545f65a82 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -1,4 +1,4 @@ -# RFC: 交互式侧会话与合并回写 +# Agent Note: 交互式侧会话与合并回写 Status: proposed @@ -15,9 +15,9 @@ Status: proposed - **Fork 并绑定:** 以父会话的平衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或 session-store 方法。 - **顾问定位:** 创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。 - **合并回写:** 向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在其日志位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md),无需新增会话事件。 -- **呈现:** 调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 RFC 仅规定与界面无关的机制。 +- **呈现:** 调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note 仅规定与界面无关的机制。 -回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据均不在本 RFC 范围内。一次 live-adapter spike 已验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 +回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据均不在本 Agent Note 范围内。一次 live-adapter spike 已验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 ## 曾考虑的替代方案 diff --git a/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml index 5a09704cff..f5f7b73ecb 100644 --- a/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-sqlite-session-query-provider.md: d1901ab0e37e8f92af322facac0cb48988d1ffe7 -2026-07-10-sqlite-session-query-provider.zh.md: 7a86d7294183eec57c3495a182f1403f84c27363 +2026-07-10-sqlite-session-query-provider.md: 828d73938e8b9de1a69d021cb10f86aa7b5cd576 +2026-07-10-sqlite-session-query-provider.zh.md: 2cb32238d388e3a1751451fe0bb2036dbac57809 diff --git a/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md b/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md index 7a86d72941..2cb32238d3 100644 --- a/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md +++ b/.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md @@ -1,4 +1,4 @@ -# RFC: SQLite FTS5 会话搜索 +# Agent Note: SQLite FTS5 会话搜索 Status: proposed @@ -6,7 +6,7 @@ Status: proposed ## 问题 -精确读取的 `ctx.sessionQuery` 服务有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不是每次查询都扫描全部事件;当前的活跃会话则需要一个比上一次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤、分页、取消以及重建行为。 +精确读取的 `ctx.sessionQuery` 服务有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不是每次查询都扫描全部事件;当前的活跃会话则需要一个比上一次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、分页、取消以及重建行为。 如果把这些关注点拆分到一个推测性的 provider 协调器和一个数据库实现之间,会产生两个耦合的协调状态机。第一个真实实现应当将源观察、提取、SQLite 事务、generation 管理和查询作为一个完整的生命周期来拥有。 @@ -22,7 +22,7 @@ Status: proposed 实现必须从可执行的用例出发定义跨会话和会话内两种搜索范围。每个可搜索事件是一个文档,包含会话元数据、事件元数据、surface 分类、归一化的语义文本和有界的纯文本摘要片段。会话级结果按其最强匹配事件分组;数值化的后端分数保持私有。 -过滤器在排序之前编译为参数化 SQL。查询语法被视为数据。排序包含稳定的平局字段。不透明游标绑定到归一化的请求形状和最小相关 generation;不相关的会话变更不应使会话内游标失效。取消操作必须停止调用方等待,并在运行时允许的范围内中断 SQLite 工作。 +搜索返回承载内容的结果记录,而非仅含元数据的 header。可链式过滤器作用于这一精确结果形状,并与搜索 API 一同设计和实现,而不会变成 provider 特有的预排序契约。查询语法被视为数据。排序包含稳定的平局字段。不透明游标绑定到归一化的请求形状和最小相关 generation;不相关的会话变更不应使会话内游标失效。取消操作必须停止调用方等待,并在运行时允许的范围内中断 SQLite 工作。 分词器选择仍是实现层面的实验。FTS5 trigram 支持子串召回,但会拒绝短于三个字符的有用词项并增大索引体积;提案在将其写入契约之前,必须对该权衡与默认 Unicode 分词器进行基准测试。 @@ -43,10 +43,10 @@ Status: proposed - 重启测试覆盖未变更、新增、变更和删除的持久化会话,且不重建整个索引。 - 重新打开时保留持久化行并移除活跃行;活跃行先遮蔽、后显露其持久化基础。 -- 测试覆盖两种搜索范围、元数据过滤、surface 默认值、摘要片段、转义、确定性平局、分页、范围内的陈旧游标、取消、动态持久化挂载/卸载,以及事务失败后的恢复。 +- 测试覆盖两种搜索范围、承载内容的结果、可链式结果过滤器、surface 默认值、摘要片段、转义、确定性平局、分页、范围内的陈旧游标、取消、动态持久化挂载/卸载,以及事务失败后的恢复。 - schema 不匹配只重置派生数据库。 - 一个 keyless 的端到端测试将真实的持久化后端与真实的 SQLite 搜索包组合使用。 -- 在移至 `implemented/` 之前,本 RFC 须修订为实际实现的分词器和公开 API。 +- 在移至 `implemented/` 之前,本 Agent Note 须修订为实际实现的分词器和公开 API。 ## 风险 diff --git a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml index 802373dd50..1ad844cdb9 100644 --- a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-13-stream-workflow-progress-through-tool-calls.md: c4fe68974bf774306038e3bd2ba3e29e06de3492 -2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: b6a3ccbc89bebaae92641a10aea9a9a05b38a293 +2026-07-13-stream-workflow-progress-through-tool-calls.md: 668d38b38cab90f6ee9b613d1101d4d44302aa8a +2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: a6d9540eb8c20d785d1395951a131feecd8ba39f diff --git a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md index b6a3ccbc89..a6d9540eb8 100644 --- a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md +++ b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md @@ -1,4 +1,4 @@ -# RFC: 通过工具调用流式传输工作流进度 +# Agent Note: 通过工具调用流式传输工作流进度 Status: proposed diff --git a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml index f7f98a72bb..8f00d8b537 100644 --- a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-api-extractor-reports.md: 26562267d188ab2427075c6fccf0ee4b24d63d99 -2026-06-11-api-extractor-reports.zh.md: 33e80abc6e9689cf90f3c851039144a53418137e +2026-06-11-api-extractor-reports.md: f110bfe3353e65442f218336aca3e9d492ac2341 +2026-06-11-api-extractor-reports.zh.md: 8cb7353e10a8811b20ccd539de15f8e06b76e6ae diff --git a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.md b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.md index b32dc7e2af..f110bfe335 100644 --- a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.md +++ b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.md @@ -2,6 +2,8 @@ Status: proposed +English | [中文](2026-06-11-api-extractor-reports.zh.md) + > Split out from the original "Doc-sync and API reports" Agent Note (2026-06-11). Parts 1-2 (doc-block typechecking, event-taxonomy verification) shipped — see [doc-sync enforcement](../../implemented/process/2026-06-11-doc-sync-enforcement.md). This is the deferred part 3, kept as a standalone proposal. ## Problem diff --git a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md index 33e80abc6e..8cb7353e10 100644 --- a/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md +++ b/.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md @@ -1,10 +1,10 @@ -# RFC: API extractor 报告 +# Agent Note: API extractor 报告 Status: proposed [English](2026-06-11-api-extractor-reports.md) | 中文 -> 从最初的「Doc-sync 与 API 报告」RFC(2026-06-11)中拆出。第 1–2 部分(文档块类型检查、事件分类体系校验)已交付,见 [doc-sync 强制](../../implemented/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 +> 从最初的「doc-sync(文档同步门禁)与 API 报告」Agent Note(agent 决策记录)中拆出(首次提出于 2026-06-11)。第 1 至第 2 部分(文档块类型检查、事件分类体系校验)已交付,见 [doc-sync 强制](../../implemented/process/2026-06-11-doc-sync-enforcement.md)。本文是被推迟的第 3 部分,作为独立提案保留。 ## 问题 diff --git a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml index b358826e19..624b671a37 100644 --- a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-architectural-conformance.md: aad11b9e4bcbd31465cb0c4a507654971e24e843 -2026-06-11-architectural-conformance.zh.md: 59684bd01a133755a3d7efd90832f6f268037920 +2026-06-11-architectural-conformance.md: f7cb0d7397d4e03df225f68417da43b1fec8de62 +2026-06-11-architectural-conformance.zh.md: aa25ef6d2772642885ef268bd548fc6dad40d3cf diff --git a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md index 59684bd01a..aa25ef6d27 100644 --- a/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md +++ b/.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md @@ -1,4 +1,4 @@ -# RFC: 架构一致性——依赖规则与适配器套件 +# Agent Note: 架构一致性——依赖规则与适配器套件 Status: proposed @@ -6,19 +6,19 @@ Status: proposed ## 问题 -目前有两项架构保证仅存在于行文中:(1)没有任何东西依赖具体的 loop 包([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确地遵循 chunk 协议。二者都应当是机械化的([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 +目前有两项架构保证仅存在于行文中:(1)没有任何组件依赖具体的 loop 包(package)([微内核承诺](../../implemented/architecture/2026-06-11-microkernel-event-taxonomy.md));(2)每个 LlmAdapter 都正确遵循分片协议。二者都应由机制强制执行([质量门禁原则](../../implemented/process/2026-06-11-quality-gates.md))。 ## 提案 **dependency-cruiser** 配合以下规则: -- `packages/*`(除 agent-loop 自身的 tests 和 examples/ 外)禁止导入 `@deepseek-ai/dsh-agent-loop`。 +- `packages/*`(除 agent-loop(智能体循环)自身的 tests 和 examples/ 外)禁止导入 `@deepseek-ai/dsh-agent-loop`。 - 禁止跨包深层导入(`@deepseek-ai/dsh-*/src/...` 路径)——只允许使用公开入口点。 - packages/ 内禁止导入循环。 - `vendor/*` 禁止从 `packages/*` 导入。 - 分层:dsh-llm 不导入其他 dsh 包;dsh-session 仅导入 dsh-llm;以此类推(packages/README.md 中的依赖表,强制执行)。 -**适配器一致性套件**位于 dsh-llm(`@deepseek-ai/dsh-llm/conformance`):一个可复用的 vitest 套件,以适配器工厂为参数,断言 chunk 协议契约——每个 block 内 index 单调递增、`block-end` 之后该 index 不再有 delta、恰好一个 `finish`、usage 至多出现一次、每个 `tool-call-delta` 携带 call id、abort 被及时响应。当前对 mock 运行;DeepSeek V4 适配器从第一天起继承该套件。可选地提供一个 dev 模式的 `strictAdapter()` 包装层,在 debug flag 下于运行时强制执行相同规则(与 [dev 模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) 配对)。 +**适配器一致性套件**位于 dsh-llm(`@deepseek-ai/dsh-llm/conformance`):一个以适配器工厂为参数的可复用 vitest 套件,用于断言分片协议契约,包括每个块内的索引单调递增、某个索引出现 `block-end` 后不再接收增量、恰好出现一个 `finish`、用量至多出现一次、每个 `tool-call-delta` 都携带调用 id,并且及时响应 abort。当前先对 mock 运行;DeepSeek V4 适配器从第一天起继承该套件。还可以选择提供开发模式下的 `strictAdapter()` 包装层,在调试标志开启时于运行时强制执行相同规则(与 [开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) 配对)。 ## 计划 @@ -33,4 +33,4 @@ Status: proposed 随着包的增加,dep-cruiser 规则需要维护——规则应基于模式(`dsh-*`)而非逐一枚举。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml index 7e89d07146..bf98665195 100644 --- a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-supply-chain-and-vendor-drift.md: 97f5a3f999936faf81a67fe69c91f773400cb447 -2026-06-11-supply-chain-and-vendor-drift.zh.md: 0a8441c104ca4779a79b36361ea5d84f5cd09aca +2026-06-11-supply-chain-and-vendor-drift.md: a27ae64556dc7366279824f1480e5681b1e86bf1 +2026-06-11-supply-chain-and-vendor-drift.zh.md: 25c27650709faf1a462ce9779ee6f0a909746311 diff --git a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md index 0a8441c104..25c2765070 100644 --- a/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md +++ b/.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md @@ -1,4 +1,4 @@ -# RFC: 供应链检查与 vendor 漂移验证 +# Agent Note: 供应链检查与 vendor 漂移验证 Status: proposed @@ -10,10 +10,10 @@ vendor manifest(元数据清单)(见[引入 vendor 的决策](../../implem ## 提案 -1. **Vendor 漂移检查**(夜间 CI):以 manifest 中记录的 SHA 浅克隆上游仓库,复制对应的 package 源码,与 `vendor/*/src` 做 diff。除非 diff 与已记录的本地修改一致(每项修改以签入的 patch 文件保存——日志条目从行文描述变为可验证的产物),否则任务失败。 -2. **依赖安全公告**:对 lockfile 运行 osv-scanner(或 `pnpm audit`),按计划定期执行,并在涉及 lockfile 变更的 PR 上触发。 +1. **Vendor 漂移检查**(夜间 CI):以 manifest 中记录的 SHA 浅克隆上游仓库,复制对应的包(package)源码,与 `vendor/*/src` 做 diff。除非 diff 与已记录的本地修改一致(每项修改以签入的 patch 文件保存——日志条目从行文描述变为可验证的产物),否则任务失败。 +2. **依赖安全公告**:对 lockfile 运行 osv-scanner(或 `pnpm audit`),按计划定期执行,并在涉及 lockfile 变更的 PR(Pull Request)上触发。 3. **许可证清单**:一个脚本断言每个 vendor 包都携带其 LICENSE 文件,且 package.json 的 `license` 字段与 vendor/README.md 中的清单一致(我们混合了 vendor 的 MIT 与自有的 BSD-3)——作为 CI 步骤运行。 -4. **Renovate**(或定时 agent 任务)以小 PR 的形式提议 npm 依赖更新,这些 PR 走完整门禁套件;vendor 包不在其列(它们的更新遵循 manifest 同步流程,理想情况下是半自动化的 agent 工作流:拉取上游、重新应用 patch、运行门禁、以更新后的 manifest 表格开 PR)。 +4. **Renovate**(或定时 agent(智能体)任务)以小 PR 的形式提议 npm 依赖更新,这些 PR 走完整门禁套件;vendor 包不在其列(它们的更新遵循 manifest 同步流程,理想情况下是半自动化的 agent 工作流:拉取上游、重新应用 patch、运行门禁、以更新后的 manifest 表格开 PR)。 ## 计划 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml index 678dcb4abe..e909f748a5 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-discover-package-inventory.md: 6729b8ea4386b1595139a2827a95cf3b072e8c94 -2026-06-20-discover-package-inventory.zh.md: 71a6fdf97255932dcff11b574ef7c67ef5a39313 +2026-06-20-discover-package-inventory.md: f2724a9512f88fccd7026db12de0a76f4d815048 +2026-06-20-discover-package-inventory.zh.md: d1a67849fd617cab0712a7c14d18ad7c7190f3c4 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md index 71a6fdf972..d1a67849fd 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md @@ -1,4 +1,4 @@ -# RFC: 通过发现机制获取包清单,而非维护静态列表 +# Agent Note: 通过发现机制获取包清单,而非维护静态列表 Status: proposed @@ -6,7 +6,7 @@ Status: proposed ## 问题 -包(package)与门禁清单在 TypeScript project references、包文档、CI 描述、Knip 覆盖项以及快照场景元数据中反复出现。大多数只是重述包布局、manifest 数据、聚合命令内容或 fixture(测试前置数据)文件。因此每新增一个包或场景都会产生本可避免的同步点。 +包(package)与门禁清单在 TypeScript project references、包文档、CI 描述和 Knip 覆盖项中反复出现。大多数只是重述包布局、manifest(元数据清单)数据或聚合命令内容。因此每新增一个包都会产生本可避免的同步点。 [包层级结构](../../implemented/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages/<group>/<pkg>` 布局推导列表,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单,主要是 `tsconfig.build.json` 的 project `references`——TypeScript 要求它是显式数组(没有通配符形式)。 @@ -14,11 +14,11 @@ Status: proposed ## 提案 -让剩余的包/门禁清单可被发现。一个唯一的权威来源——`packages/<group>/<pkg>` 层级结构加上包 manifest(元数据清单)——应当驱动 `tsconfig.build.json` 的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写出产物,`hygiene`/`doc-sync`(文档同步门禁)中的 `--check` 模式在提交副本陈旧时报错)。模块图生成已经在读取包 manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令而非重述第二份列表。 +让剩余的包与门禁清单可被发现。一个唯一的权威来源,即 `packages/<group>/<pkg>` 层级结构加上包 manifest,应当驱动 `tsconfig.build.json` 的 `references`、模块图以及任何全量包列表,并配合一个生成加校验步骤(沿用现有的 `gen-module-graph` / `gen-cordis-catalog` 模式:生成器写出产物,`--check` 模式在 `hygiene` / `doc-sync`(文档同步门禁)中发现已提交副本陈旧时失败)。模块图生成已经在读取包 manifest。`doc-sync` 应当成为定义并打印其子门禁的唯一命令,文档链接到该命令,而非重述第二份列表。 层级结构不需要编码关于包的所有事实,但应当编码宽泛的维护策略:core/product 包、集成包、能力 seam 包与 support/test/example 包不应在脚本能区分它们之前先要求一份手工维护的例外列表。 -有两类编目项根本不需要生成器:将 e2e 入口 glob 折入 knip 的默认配置段即可直接删除逐包的重复声明;`childSessions` 可从每个场景的 fixture 目录发现,使场景表只需声明策略(`recorded`、`hasModelTurn`、`comparesLog`)。而且即便是这些策略字段,今天也在追踪可从 fixture 推导的事实(`comparesLog` ⟺ 已提交的日志在头行之后还有条目;`recorded` ⟺ `hasModelTurn` 且没有 `replay.override.json` 兄弟文件),因此每个新场景类都在不断添加 fixture 目录本身已经能回答的开关。 +有一项已编目的内容根本不需要生成器:将 e2e 入口 glob 折入 Knip 的默认配置段,即可直接删除逐包的重复声明。 ## 验收标准 @@ -27,10 +27,9 @@ Status: proposed - 文档描述真源,而非重复生成的清单。 - CI 调用聚合命令,由这些命令自行管理其子门禁列表。 - `knip.json` 仅在编码真实信息(额外入口文件、被忽略的依赖)时才携带逐包覆盖项,绝不重述默认配置段。 -- 快照场景只声明策略,不声明可从其 fixture 目录发现的事实。 ## 风险 -发现脚本可能变得过于精巧。实现应当保持朴素:读取 manifest、按显式字段过滤、打印解析后的列表、出错时大声报错。收益在于消除手工清单的漂移,而非发明一套构建系统。 +发现脚本可能变得过于精巧。实现应当保持朴素:读取 manifest、按显式字段过滤、打印解析后的列表,并在出错时明确失败。收益在于消除手工清单的漂移,而非发明一套构建系统。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index d828a634b5..3affcaabfa 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-dead-core-spine-surface.md: 59bbfa181a08b998c52ef63afbc63fd5226294a6 -2026-07-04-prune-dead-core-spine-surface.zh.md: 953cb3bc7b30affd505564ac427632732dd9374e +2026-07-04-prune-dead-core-spine-surface.md: 432aaa848540fe7d1db3039399345a02afed5bd3 +2026-07-04-prune-dead-core-spine-surface.zh.md: 6352e2ea1ec6ef4459fc07472e5dbdb6cfb648a5 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 953cb3bc7b..6352e2ea1e 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 裁剪无用的公开与结果接口 +# Agent Note: 裁剪无用的公开与结果接口 Status: proposed @@ -6,29 +6,30 @@ Status: proposed ## 问题 -若干包根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入了内部实现,要么是因为某个类型预期了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的 catalog、文档和回归矩阵,却没有支撑任何已交付的路径。 +若干包(package)根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入了内部实现,要么是因为某个类型预期了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的 catalog、文档和回归矩阵,却没有支撑任何已交付的路径。 -生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包(package) README 和 RFC 行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: +生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包 README 和 Agent Note(agent 决策记录)行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: | 接口 | 生产证据 | 简化方式 | | --- | --- | --- | | `SurfaceManager.invalidate()` | 只有其单元测试调用它;seeding 在惰性创建的 manager 存在之前就已完成,且会话从不替换其日志引用。 | 删除它及其不可能触发的整体替换契约。 | | `ToolExecutionResult.callId` | 每个钩子已经接收不可变的 `ToolExecution`;循环和 ACP(Agent Client Protocol)通过 call/session 事件关联。没有消费方读取这个重复的结果字段。 | 移除该字段、复制/不匹配守卫,以及证明该重复不可能不一致的测试。 | | `ReactLoopAgent` 根导出 | 包外的命名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 返回/接口类型为 `Agent`,将具体循环类改为包内部;保留有意设计的同步、仅配置的 `AgentLoop.create()` 路径。 | -| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 每个包名消费方都使用默认引擎;workflow RFC 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 | +| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 每个包名消费方都使用默认引擎;workflow Agent Note 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 | | `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 | | ACP 的 translation/presenter 根导出 | `agentOptions`、`streamSessionEventUpdate`、`todosToPlan`、`ToolPresenter`、`nullToolPresenter` 和 `TerminalRendering` 只有同文件或 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 translation/presentation 辅助函数改为源码私有,在包内测试。 | -| `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试 provider 行为。 | +| `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 | | `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 改为源码私有;通过 spawn 和 dispose 测试。 | | `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 | | `LlmError.status` 与 replay status | 适配器/replay 填充它,但生产分支基于稳定的 error code/message 判断,从不读取原始 status。 | 移除未读字段和 replay 管道,保留错误分类。 | | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 | | `compactRegion` 的独立 `session` 参数 | 固定调用者传入的对象与 `agent.session` 上已有的是同一个;模型可见的 mount API 也能调用该方法,但接受两个身份允许挂载的插件提供不一致的配对。 | 保留手动 region seam,同时有意将其收窄为以 `agent.session` 为唯一真源。 | | `CompactionResult.startSeq`、`summarySeq`、`endSeq` 与 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有 summary 和事件标识。 | 移除四个结果回显,保留两个共享的 transcript(文本记录)渲染器。 | -| `BasicCompactService` 的 estimation/summarization 可见性 | 没有包外生产调用者调用这五个方法;已实现的 RFC 只将 `estimateContentTokens()` 和 `summarize()` 命名为子类钩子。 | 将这两个方法改为 `protected`,其余三个编排专用的估算器改为 private。 | +| `BasicCompactService` 的 estimation/summarization 可见性 | 没有包外生产调用者调用这五个方法;已实现的 Agent Note 只将 `estimateContentTokens()` 和 `summarize()` 命名为子类钩子。 | 将这两个方法改为 `protected`,其余三个编排专用的估算器改为 private。 | | `CodeLogEntry.source`/`level` 与 `RunCodeMeta.dispatches` | 每个生产消费方都将日志映射为文本;没有 presenter/模型路径读取其他字段或持久化的 dispatch 计数。 | 将 code-runtime 日志改为字符串(或纯文本条目),移除 result-meta 的 dispatch 管道;保留用于生成确定性 dispatch id 的本地计数器。 | +| `CodeRuntime.language` 与 `CodeRuntime.isolation` | worker 后端提供唯一的生产值,而 Code Mode 及其他所有生产调用方只调用 `run()`。 | 移除未读描述符,同时保留 worker 的语言、隔离、预算、取消与资源释放行为。 | | `ToolNotFoundError.toolName`、`SystemPrompt.config` 与 `BashTask.command` | 每个存储的公开值都没有生产读取者。 | 移除未读字段,保留错误消息、已解析的配置行为和任务生命周期。 | -| 后端包根实现辅助函数 | 下方精确清单仅通过相对路径的同包导入调用。生产命名空间导入挂载的是保留的插件契约,不读取这些属性;命名根消费方都是测试。 | 保留每个适配器/provider/服务及其配置/错误契约;停止在包根导出所列辅助函数/常量。 | +| 后端包根实现辅助函数 | 下方精确清单仅通过相对路径的同包导入调用。生产命名空间导入挂载的是保留的插件契约,不读取这些属性;命名根消费方都是测试。 | 保留每个适配器/提供方/服务及其配置/错误契约;停止在包根导出所列辅助函数/常量。 | | 消费方包根实现辅助函数 | 下方精确清单只有同包生产调用者。生产命名空间导入挂载的是插件契约,不读取辅助属性;命名根消费方都是测试。 | 保留插件契约和稳定的错误码;将测试迁移到包内模块或公开行为,停止在包根导出所列辅助函数。 | ### 分组辅助导出清单 @@ -48,14 +49,14 @@ Status: proposed **保留测试便利函数和自包含的结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更符合人体工学,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;当前它们让每处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小契约,其所有权和失败语义明确。 -**保留所有 catalog 成员以供模型编写的 mount 使用。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务接口,而非无限期保留重复字段或不一致的参数对;上述每一项 catalog 收缩都移除了在同一 execution、agent 或 result 上其他位置已可获得的事实,并在同一变更中更新 API 参考。 +**保留所有 catalog 成员以供模型编写的 mount 使用。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务接口,而非无限期保留重复字段或不一致的参数对;上述每一项 catalog 收缩都移除了在同一 execution、agent(智能体)或 result 上其他位置已可获得的事实,并在同一变更中更新 API 参考。 ## 验收标准 -- 精确符号搜索显示:在本 RFC 及任何已实现 RFC 修正之外,没有被移除的接口。 -- 本 RFC 列出的每个接口均按指定方式缺失或降级;清单之外有意保留的扩展/测试契约不变。 +- 精确符号搜索显示:在本 Agent Note 及任何已实现 Agent Note 修正之外,没有被移除的接口。 +- 本 Agent Note 列出的每个接口均按指定方式缺失或降级;清单之外有意保留的扩展/测试契约不变。 - 工具执行、上下文压缩(context compaction)、两个 LLM 适配器、两个持久化后端、workflow 隔离以及 agent 创建/恢复保持其已交付行为。 -- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建和 hygiene 通过。 +- 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建和 hygiene 通过。 ## 风险 diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml index 4c6aa9f685..aa9ff3e5ac 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-deterministic-and-stress-testing.md: c629567fd16158a0bd081e7e7850fb3e6d5f1631 -2026-06-11-deterministic-and-stress-testing.zh.md: aaa170725f9d3d2457d6f418dce7bc53feda2ab7 +2026-06-11-deterministic-and-stress-testing.md: d9977be835af05f9ee303b63ec6015bc9e153170 +2026-06-11-deterministic-and-stress-testing.zh.md: 8aed4ad6f277c1e567d91e3a8e2fef1c350ab89c diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md index aaa170725f..8aed4ad6f2 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md @@ -1,4 +1,4 @@ -# RFC: 确定性测试、回放不变式 fixture 与竞态压力测试 +# Agent Note: 确定性测试、回放不变式 fixture 与竞态压力测试 Status: proposed @@ -12,7 +12,7 @@ Status: proposed 三项措施: -1. **测试中禁止挂钟睡眠。** 将 `setTimeout(N)` 等待替换为事件驱动等待(既有的 `waitForIdle` 模式,扩展为 `waitForStatus`、`waitForEvent(n)`),或在需要测试时间本身时使用 vitest 的 fake timer。通过 lint 规则强制执行:禁止在 `packages/*/tests` 中使用 `setTimeout`,白名单辅助模块除外。 +1. **测试中禁止挂钟睡眠。** 将 `setTimeout(N)` 等待替换为事件驱动等待(既有的 `waitForIdle` 模式,扩展为 `waitForStatus`、`waitForEvent(n)`),或在需要测试时间本身时使用 vitest 的 fake timer。通过 lint 规则禁止 `setTimeout`,适用范围是 `packages/*/tests`,白名单辅助模块除外。 2. **通用回放 fixture(测试前置数据)。** 一个共享测试辅助函数包装 agent loop harness,使每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被套件产生的所有场景检查数百次,而非仅两次。 3. **夜间竞态压力测试。** 一个 CI job 以 `vitest --repeat=200`(加 `--shuffle`)运行 agent-loop 和 inbox 套件,以暴露调度依赖的失败;发现的任何不稳定测试都视为 bug 修复,绝不靠重试掩盖。 @@ -22,7 +22,7 @@ Status: proposed ## 验收标准 -- `packages/*/tests` 中不再有 `setTimeout`(白名单辅助模块除外),由 lint 规则强制执行。 +- 不再使用 `setTimeout`;lint 规则在 `packages/*/tests` 中强制执行,白名单辅助模块除外。 - 共享 harness 将每个测试的会话日志回放到全新的 `Session` 中,并自动断言 `deriveMessages()` 相等,覆盖整个套件。 - 夜间 job 以 `--repeat` 和 `--shuffle` 运行 agent-loop 和 inbox 套件;发现的不稳定测试作为 bug 分诊,绝不靠重试掩盖。 @@ -30,4 +30,4 @@ Status: proposed Fake timer 与 agent loop 中的 Promise 调度存在微妙交互——优先使用事件驱动等待;仅在测试 timer 服务行为本身时才使用 fake timer。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml index ce9a12799f..d593656c04 100644 --- a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml +++ b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-mutation-testing.md: 20b24de385b944c27f4bdc0fc70f335d827f50a0 -2026-06-11-mutation-testing.zh.md: 780d3417cce48ee19ac8e3dc3b74d78b8e2a4c0f +2026-06-11-mutation-testing.md: 591d9012644a19ee2c67a916b63092d79f78db1f +2026-06-11-mutation-testing.zh.md: 9c22ed2f42e5c44e6be98f132614886bbdb188fd diff --git a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md index 780d3417cc..9c22ed2f42 100644 --- a/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md +++ b/.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md @@ -1,4 +1,4 @@ -# RFC: 变异测试作为覆盖率的制衡手段 +# Agent Note: 变异测试作为覆盖率的制衡手段 Status: proposed @@ -10,16 +10,16 @@ Status: proposed ## 提案 -在 `packages/*/src` 上运行 Stryker(`@stryker-mutator/vitest-runner`): +使用 Stryker(`@stryker-mutator/vitest-runner`)对 `packages/*/src` 运行变异测试: -- **PR 范围的增量运行**(仅变更文件),作为一个 CI job。调优后速度足以作为合并门禁。 +- **PR(Pull Request)范围的增量运行**(仅变更文件),作为一个 CI job。调优后速度足以作为合并门禁。 - **每夜全量运行**,跟踪变异分数;先记录基线,再将阈值设为观测到的基线并只升不降(与覆盖率策略一致:阈值只收紧)。 - 存活的变异体是待办项:agent 选取一个存活体、编写杀死它的测试、循环往复——一个形态良好的自主循环。 - 等价变异体(可证明不改变行为的)加注释排除并附理由,与 `/* v8 ignore */` 策略一致。 ## 计划 -1. 添加 Stryker 配置,范围限定在一个包(llm:最小、最具算法性),测量运行时间。 +1. 添加 Stryker 配置,范围限定在一个包(package),即 llm(最小、最具算法性),并测量运行时间。 2. 扩展到所有包;在配置中记录基线分数。 3. 接入每夜 job;运行时间可接受后再添加 PR 范围的增量 job。 @@ -33,4 +33,4 @@ Status: proposed 运行时间:变异测试开销大;逐文件 100% 覆盖率有所帮助(每个变异体至少会被执行到)。如果 PR 范围的运行始终过慢,则保持仅每夜运行,依赖分数只升不降的机制。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml index 31b046d91a..89414acd96 100644 --- a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml +++ b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-immutable-public-surfaces.md: c807b036bb57bd5e64290fd59bf422c4732e9080 -2026-06-11-immutable-public-surfaces.zh.md: 9ff40436915e194848ba163ed80fefe84a1b3da4 +2026-06-11-immutable-public-surfaces.md: c9009ad923720efaecb25e2017ceab6e3eb0dbf4 +2026-06-11-immutable-public-surfaces.zh.md: 2516450c61eab399b2cb64862af164289eb87fbe diff --git a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md index 9ff4043691..2516450c61 100644 --- a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md +++ b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md @@ -1,4 +1,4 @@ -# RFC: 深度只读的公开接口 +# Agent Note: 深度只读的公开接口 Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). @@ -26,4 +26,4 @@ Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by so `DeepReadonly` 类型在 waterfall 边界处(突变本身就是 API 的地方)可能产生噪音较大的错误。应将可变/只读边界精确地划在「已记录 vs 进行中」,并在 session README 中加以说明。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml index e4d9370aa8..c19ca144a0 100644 --- a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml +++ b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-providerless-example-base.md: ca9d391172067aca980b5b0fbc17141320c6839e -2026-06-20-providerless-example-base.zh.md: fb64e4a0b5295e0d56e7cd598c230f56219ba77e +2026-06-20-providerless-example-base.md: 2f41476a487775e6f9da2f113efe566e44786ff3 +2026-06-20-providerless-example-base.zh.md: 61d9f819b530fa9a15e8ed5b9d3f13f945e10b82 diff --git a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md index fb64e4a0b5..61d9f819b5 100644 --- a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md +++ b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md @@ -1,4 +1,4 @@ -# RFC: 使共享示例基础配置与提供方无关 +# Agent Note: 使共享示例基础配置与提供方无关 Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-spine-demo` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. @@ -22,10 +22,10 @@ Status: rejected — superseded by [Extract example apps into packages](../../im - `examples/base-core.yml` 已删除。 - 真实演示配置显式添加 DeepSeek 适配器。 - 快照回放配置 include 同一个与提供方无关的基础,并加入其回放适配器。 -- [examples README](../../../../examples/README.md)、各示例 README 及 RFC 引用不再解释「base = base-core 加适配器」。 +- [examples README](../../../../examples/README.md)、各示例 README 及 Agent Note 引用不再解释「base = base-core 加适配器」。 ## 放弃了什么 真实演示失去了一层便利:每个演示都必须显式引入适配器。对于示例而言这是正确的默认行为,因为适配器选择是可变部分,而与提供方无关的接线才是共享的产品核心。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml index da8988c6ea..306dac3774 100644 --- a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml +++ b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-generate-rfc-index-tables.md: 5d5ae258583e0fae7dcb44a23c4b41446bd233d2 -2026-07-04-generate-rfc-index-tables.zh.md: fa611e4337075501fe097fe8fc68a878a855a7c0 +2026-07-04-generate-agent-note-index-tables.md: 6e5221f018942a0629f30b6e6f22cedfb9f4145e +2026-07-04-generate-agent-note-index-tables.zh.md: f299fc5c2df25064a2712fa177fdbe5b73ffc7ca diff --git a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md index 90c942411d..6e5221f018 100644 --- a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md +++ b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.md @@ -2,6 +2,8 @@ Status: rejected — a centralized generated list is merge-prone and adds little discovery value +English | [中文](2026-07-04-generate-agent-note-index-tables.zh.md) + ## Problem Per-lifecycle/per-class tables would list facts that are fully derivable: an Agent Note's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts would also be a high-contention docs hotspot because concurrent Agent Note branches append rows to the same few lines. [The classification Agent Note](../../implemented/process/2026-06-20-agent-note-classification.md) makes the tree itself authoritative. diff --git a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md index fa611e4337..f299fc5c2d 100644 --- a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md +++ b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md @@ -1,34 +1,38 @@ -# RFC: 生成 RFC 索引表 +# Agent Note: 生成 Agent Note 索引表 -Status: implemented +Status: rejected — a centralized generated list is merge-prone and adds little discovery value -[English](2026-07-04-generate-rfc-index-tables.md) | 中文 +[English](2026-07-04-generate-agent-note-index-tables.md) | 中文 ## 问题 -RFC 索引中按生命周期/按分类的表格所列信息完全可以推导:RFC 的路径编码了生命周期与分类,文件名编码了首次提出日期,H1 标题承载了标题文本。这些信息的手工维护副本也是仓库中冲突最频繁的文档热点:每一波提案都在同几行后追加新行,因此并发的 RFC 分支恰好在此处冲突,而其他地方完全一致;每次冲突都要手工合并那些文件系统本已知晓的行。[分类 RFC](2026-06-20-rfc-classification.md) 最初为了可策展性而保留手写索引,但 README 中真正需要策展的是行文,而行文从不冲突;冲突的只有机械表格。 +按生命周期和分类划分的表格只会列出完全可推导的事实:Agent Note(agent 决策记录)的路径编码其生命周期和分类,文件名编码首次提出日期,H1 承载标题。手工维护这些事实的副本还会成为高冲突文档热点,因为并发的 Agent Note 分支会向相同的几行追加条目。[分类 Agent Note](../../implemented/process/2026-06-20-agent-note-classification.md) 将目录树本身定为权威来源。 -## 决策 +## 提案 -保留策展行文;生成列表。表格位于 [`docs/rfc/INDEX.md`](../../INDEX.md),是一个**完全生成的文件**——策展行文留在 README.md 中,README.md 不包含任何索引行。[`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) 是共享的真源:树遍历器(拥有封闭的生命周期/分类集合与结构规则,包括对可解析 H1 的要求)和渲染器(行来自 H1 标题并去掉 `RFC: ` 前缀,加上文件名日期,按日期再按文件名排序,以 `### {Class}` 分节、按规范分类顺序分组)。两个轻量消费方共享它: +保留策展文本,并将列表生成为完全生成的 `.agents/notes/INDEX.md`。共享的 `scripts/agent-note-index.ts` 模块将同时负责目录树遍历器和渲染器。两个轻量消费方会共用它: -- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts)(`pnpm run gen-rfc-index`)从目录树完整重写 INDEX.md。 -- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts)(`doc-sync`(文档同步门禁)的一个成员)检查结构,断言已提交的 INDEX.md 与新鲜渲染结果逐字节一致(`gen-cordis-catalog`/`verify-cordis-catalog` 模式),并拒绝在策展 README 中出现索引格式的行。新鲜度检查涵盖了索引完整性检查:从磁盘生成的表格在定义上就是完整的、标题正确的。 +- `scripts/gen-agent-note-index.ts`(`pnpm run gen-agent-note-index`)将根据目录树完整重写 INDEX.md。 +- `scripts/verify-agent-note-classification.ts` 将检查结构,并断言已提交的 INDEX.md 与新鲜渲染结果逐字节一致。 -添加、移动或删除一个 RFC 只需编辑 RFC 文件本身并运行生成器;分类 RFC 的「已否决替代方案」记录中带有替代关系的交叉链接。 +添加、移动或删除 Agent Note 时,只需编辑 Agent Note 文件并运行生成器。 ## 曾考虑的替代方案 -### 为什么不在 README.md 内使用标记分隔区域? +### 为什么不在 README.md 中使用标记分隔区域? -最初落地的形态是:生成器将表格拼接到 README.md 中 `gen-rfc-index` 标记注释之间、各 `## {Lifecycle}` 标题之下。在 README 同时吸收了文件内格式契约([统一格式 RFC](2026-07-05-uniform-rfc-format.md))之后,被整文件 INDEX.md 方案取代:一个门面 README 承载数百行生成内容会淹没其策展行文,而拼接机制(标记对、标题检查、区域外行检测)的存在仅仅是为了保护策展文本——专用的生成文件根本不包含这类文本。 +README.md 中由标记分隔的表格会混合生成内容与策展文本,因而需要拼接机制并保护周围的契约。专用生成文件至少能将这些关注点分开。 ### 为什么不采用纯校验器模式? -校验器能捕获错误,但每次提案编辑仍然要在手工维护的表格中触碰共享热点;对于纯机械的行,校验器失败比生成器更令人烦恼:作者已经命名并放置了文件,索引副本不增加任何信息。这与 [package-inventory 提案](../../proposed/process/2026-06-20-discover-package-inventory.md) 对 tsconfig references 和 knip stanzas 所做的手写列表与推导之间的判断一致——应用于这张确实会冲突的列表。 +它能捕获错误,但每次提案编辑仍然要在手工维护的表格中触碰共享热点。作者已经命名并放置了文件,因此索引副本不增加任何信息。这与[包清单提案](../../proposed/process/2026-06-20-discover-package-inventory.md)对 tsconfig 引用和 knip 配置段所做的手写列表与推导之间的判断相同。 ## 后果 -- 生成文件是显式的:其横幅标注了生成器名称,文件内没有需要保护的策展区域,且生成器在目录树结构无效时拒绝运行。 -- 格式错误或缺失的 H1 在生成器和门禁中都是硬错误——H1 现在是索引标题的承重来源。 -- 并发的 RFC 分支通过重新运行生成器解决索引冲突,从不手工合并行。 +- 生成文件将是显式的,且不包含任何策展区域。 +- H1 格式错误或缺失将是硬错误,因为 H1 为每一行提供标题。 +- 即使可以通过重新运行生成器解决冲突,并发分支仍会修改同一个已提交产物。 + +## 相关 + +已落地的[不建立索引决策](../../implemented/process/2026-07-19-remove-generated-agent-note-index.md)保留目录树和仓库搜索作为发现机制。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml index c203585372..adeb1b14f9 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-assembled-assistant-messages-only.md: 48f45ba08f77e2efd79bd999e262afad313bbdfe -2026-06-20-assembled-assistant-messages-only.zh.md: 44d94b3bc3d9c9f0f6c3bb8a2554664c4f5f5c59 +2026-06-20-assembled-assistant-messages-only.md: ba8135a3d63f292cfedd23de8b4b9d43b4455e8c +2026-06-20-assembled-assistant-messages-only.zh.md: 963d807afef29a3f7d8f0f57fc89ef7f6f3187c4 diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md index 44d94b3bc3..963d807afe 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md @@ -1,4 +1,4 @@ -# RFC: 仅持久化组装后的 assistant 消息,不存储流式分片 +# Agent Note: 仅持久化组装后的 assistant 消息,不存储流式分片 Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. @@ -6,23 +6,23 @@ Status: rejected — high-fidelity chunk replay, partial failed streams, and sna ## 问题 -当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 RFC](../../implemented/architecture/2026-06-14-session-persistence.md) 选择这一方案是为了 token 级别的回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的 delta 记录占据,快照场景通过分组 chunk 事件来回放模型,ACP(Agent Client Protocol)加载时从 chunk 重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级别的追踪。 +当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 Agent Note(agent 决策记录)](../../implemented/architecture/2026-06-14-session-persistence.md)选择这一方案是为了 token 级别的回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的 delta 记录占据,快照场景通过对分片事件分组来回放模型,ACP(Agent Client Protocol)加载时从分片重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级别的追踪。 -对于成功组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复会话状态无需 chunk 即已具备;chunk 是实时渲染和确定性测试的产物,不是必需的会话历史。失败或中止的流则不同:部分 assistant 输出可能仅以 chunk 形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。 +对于成功组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复会话状态无需分片即已具备;分片是实时渲染和确定性测试的产物,不是必需的会话历史。失败或中止的流则不同:部分 assistant 输出可能仅以分片形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。 ## 提案 停止在规范会话日志中存储 `assistant/chunk`。持久日志保留 `assistant/message`、`tool/call`、`tool/result`、`usage`(如保留)以及轮次边界。实时 UI 仍可通过一个刻意设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从记录的适配器产物中派生,而非将规范的用户会话当作 token 磁带。需要部分失败流输出的场景必须在回放 fixture 中记录该输出。 -ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而非模拟原始的 token 流。加载后的 transcript(文本记录)无需重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并以有效的 provider 历史恢复运行。 +ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而非模拟原始的 token 流。加载后的 transcript(文本记录)无需重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并以有效的提供方历史恢复运行。 ## 验收标准 - `SessionEventMap` 移除 `assistant/chunk`,或在需要过渡性实时事件时将其标记为非持久化。 - [会话持久化文档](../../../../packages/session-persistence/session-persistence/README.md)不再要求逐字存储每个流式分片。 -- `llm-replay` 和 ACP 快照使用显式的回放 fixture 格式或伴随文件来存储模型 chunk。 +- `llm-replay` 和 ACP 快照使用显式的回放 fixture 格式或伴随文件来存储模型分片。 - `session/load` 从 `assistant/message` 渲染已完成的 assistant 消息。 -- 存储的日志大幅缩小,且在没有 chunk 空洞的情况下保持 `seq` 连续。 +- 存储的日志大幅缩小,且在没有分片缺口的情况下保持 `seq` 连续。 - 会话格式版本与已记录的 fixture 一并刷新;按预发布格式策略拒绝非当前版本的存储日志。 ## 放弃了什么 @@ -31,6 +31,6 @@ ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回 ## 相关 -本 RFC 取代 [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md) 中关于 chunk 持久化的决策,并影响 [ACP 快照测试](../../implemented/testing/2026-06-19-acp-snapshot-tests.md)——其当前的回放插件从 `assistant/chunk` 事件派生脚本。 +本 Agent Note 取代 [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md) 中关于分片持久化的决策,并影响 [ACP 快照测试](../../implemented/testing/2026-06-19-acp-snapshot-tests.md)——其当前的回放插件从 `assistant/chunk` 事件派生脚本。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml index 764ea5f4e9..052b380049 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-acp-session-load.md: 93a2791d10b589cfdee5ecc48922722fe27c1c8a -2026-06-20-drop-acp-session-load.zh.md: 94de0a5aa0436dbee8e78fc2dd6de72c98216768 +2026-06-20-drop-acp-session-load.md: 2b6edf173506929f2d64e910b47aa70fb4f1d854 +2026-06-20-drop-acp-session-load.zh.md: 8e7a4ed6d7d1ccc89da3340907c11d1cf35505bd diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md index 94de0a5aa0..8e7a4ed6d7 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除 ACP session/load,直到 resume 具备产品形态 +# Agent Note: 移除 ACP session/load,直到恢复具备产品形态 Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. @@ -8,15 +8,15 @@ Status: rejected — Zed is the current target ACP client, advertises and exerci ACP(Agent Client Protocol)声明 `loadSession: true` 并实现 `session/load`:向 bridge 注入持久化能力、校验 cwd 与存储元数据的一致性、从持久化日志重建 agent(智能体),并向客户端回放先前的 transcript(文本记录)更新。该路径有自己的竞态处理、loading-id 守卫、回放展示逻辑和测试。它还依赖规范日志保留足够的 UI 数据,以重建旧的分片和工具展示。 -持久化仍然是基础能力,但编辑器可见的 resume 尚未经过产品流程设计。目前没有会话选择器、没有标题/预览元数据,也没有明确的加载失败或部分加载的用户体验。bridge 正在为一个仅被测试、文档和当前目标客户端的会话模型所使用的功能付出复杂度代价。 +持久化仍然是基础能力,但编辑器可见的恢复尚未经过产品流程设计。目前没有会话选择器、没有标题/预览元数据,也没有明确的加载失败或部分加载的用户体验。bridge 正在为一个仅被测试、文档和当前目标客户端的会话模型所使用的功能付出复杂度代价。 ## 提案 -当前阶段,ACP 仅启动全新会话。`initialize` 声明 `loadSession: false` 或省略该能力,`session/load` 不予支持。持久化仍可供 agent loop(智能体循环)和测试使用;如果其他消费方需要,resume 仍可作为底层工厂存在。编辑器 bridge 应在具备真正的会话选择 UX 和稳定的 load transcript 契约后,再重新引入 `session/load`。 +当前阶段,ACP 仅启动全新会话。`initialize` 声明 `loadSession: false` 或省略该能力,`session/load` 不予支持。持久化仍可供 agent loop(智能体循环)和测试使用;如果其他消费方需要,恢复仍可作为底层工厂存在。编辑器 bridge 应在具备真正的会话选择 UX 和稳定的 load transcript 契约后,再重新引入 `session/load`。 ## 验收标准 -- ACP 不再仅为 `session/load` 注入 `sessionPersistence`。 +- ACP 不再注入 `sessionPersistence`;它原本仅供 `session/load` 使用。 - `initialize` 不再声明 load 支持。 - `session/load` handler、loading-id 追踪、已加载会话的 cwd 预检以及 load 回放测试均被移除。 - 快照 fixture(测试前置数据)不再依赖 load 回放展示。 @@ -26,4 +26,4 @@ ACP(Agent Client Protocol)声明 `loadSession: true` 并实现 `session/load 编辑器无法通过 ACP 重新打开先前持久化的会话。这确实是一项产品功能,但当前实现超前于 UX 设计,且将 bridge 绑定到 token 级别的日志回放。保留持久化但移除编辑器 load,可将 bridge 收窄到它当前能干净呈现的工作流。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index 81bb11c433..6ead85178e 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-acp-terminal-meta.md: e52187d9786ed44ef4b60aedf5396c19a2e8e872 -2026-06-20-drop-acp-terminal-meta.zh.md: a5f4d7bc1f9d3d050c23990e89610d3eaed5bf70 +2026-06-20-drop-acp-terminal-meta.md: 79da387ac1a7a0e6767e3bf24baa6039e39ef90d +2026-06-20-drop-acp-terminal-meta.zh.md: 5aa455959c403d7e03b36be0d4336fdc5389c52e diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index a5f4d7bc1f..5aa455959c 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除 ACP 终端 `_meta` 渲染 +# Agent Note: 移除 ACP 终端 `_meta` 渲染 Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. @@ -6,7 +6,7 @@ Status: rejected — Zed is the current target client, and the terminal `_meta` ## 问题 -ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 +ACP(Agent Client Protocol)桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 Agent Note(agent 决策记录)](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md)刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能特性,而非推测性装饰。 @@ -22,10 +22,10 @@ ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.t - `TerminalRendering`、终端 id、终端 cwd 解析与 `_meta.terminal_*` update 映射从 `@deepseek-ai/dsh-acp` 中消失。 - `ToolTerminal` 从 `@deepseek-ai/dsh-tools` 中消失,或在展示清理中因未使用而删除。 - Bash 结果展示不再为终端 pill 解析退出状态。 -- 已实现的[富 ACP bash 渲染 RFC](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 作为已交付历史保留在 `implemented/` 中;如被本提案取代,则加上交叉链接。 +- 已实现的[富 ACP bash 渲染 Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 作为已交付历史保留在 `implemented/` 中;如被本提案取代,则加上交叉链接。 ## 放弃的内容 Zed 用户将失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。他们仍能以纯内容形式看到命令和输出。在 ACP 桥接层尚未发布、`_meta` 键只是约定而非标准的阶段,这是合理的简化。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml index 304c4a62ac..676d14dd4e 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-bash-output-spill-files.md: 939f99072eace71405ae96e270d5438e75713c1c -2026-06-20-drop-bash-output-spill-files.zh.md: 4a868b1971dc3abcb4a9d0442ffc6e74f5246d00 +2026-06-20-drop-bash-output-spill-files.md: b2bd1a04ee1524bab29814ffa7c22712a83ee5f7 +2026-06-20-drop-bash-output-spill-files.zh.md: c8c0df30eda2ab6800d1e8f814d05f8f15103fd2 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md index 4a868b1971..c8c0df30ed 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除 bash 完整输出溢出文件 +# Agent Note: 移除 bash 完整输出溢出文件 Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. @@ -14,7 +14,7 @@ Status: rejected — full-output recovery is a real bash behavior. A future arti 保留尾部截断,移除完整输出溢出文件。bash 结果包含有界的尾部内容加一个明确的截断标记;不输出路径。如果用户需要恢复完整输出,则添加一个通用的产物/blob 服务(具有明确的所有权、清理和 UI 渲染),然后让 bash 将大体量输出附加到该服务。 -本提案可以独立于[通用长时间运行工具运行时](../../proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md)落地。如果后台任务保留,`bash_output` 仍应报告输出已被丢弃,但不再提供溢出路径。 +本提案可以独立于[通用长时间运行工具运行时](../../implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)落地。如果后台任务保留,`bash_output` 仍应报告输出已被丢弃,但不再提供溢出路径。 ## 验收标准 @@ -22,10 +22,10 @@ Status: rejected — full-output recovery is a real bash behavior. A future arti - `OutputCollector` 仅保留有界缓冲区,删除临时文件机制。 - `renderResult()` 报告截断时不包含文件系统路径。 - 测试覆盖尾部截断,不再断言完整输出文件的内容。 -- [docs/defensive-patterns.md](../../../defensive-patterns.md) 中的安全指导不再将私有溢出文件视为面向模型的接口。 +- [docs/defensive-patterns.md](../../../../docs/defensive-patterns.md) 中的安全指导不再将私有溢出文件视为面向模型的接口。 ## 放弃的能力 模型或用户无法再从临时文件恢复大体量命令输出中被省略的前缀。在真正的产物服务出现之前,这是可以接受的。当前的溢出路径为一个生命周期和权限均未经设计的功能引入了过多的定制机制。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml index 131544ab6d..d91be8381f 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-durable-step-boundaries.md: 16fdf17c3bc8907745df17e8a105d7978eafb274 -2026-06-20-drop-durable-step-boundaries.zh.md: 5613a84d5f1b5cf87109a2e04a4cb350ffd650a8 +2026-06-20-drop-durable-step-boundaries.md: c5c4f269a378e334c4dc509d1288146d77d9a520 +2026-06-20-drop-durable-step-boundaries.zh.md: 7724a8f6c2c51650eb9ea0a6675ece7c24a211d8 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md index 5613a84d5f..7724a8f6c2 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除持久化的步骤边界事件 +# Agent Note: 移除持久化的步骤边界事件 Status: rejected — `step/end` is the durable indication that a model step finished, and keeping the symmetric `step/start` / `step/end` pair makes crash repair, invariants, and transcript inspection clearer than inferring completion from adjacent step-scoped events. @@ -6,13 +6,13 @@ Status: rejected — `step/end` is the durable indication that a model step fini ## 问题 -会话日志存储了 `step/start` 和 `step/end` 事件,尽管每个步骤作用域的事件本身已经携带 `{ turn, step }`:assistant 分片、assistant 消息、工具调用、工具结果、用量和错误。`deriveMessages()` 忽略步骤边界,ACP(Agent Client Protocol)在 UI 层面也忽略它们,主要消费方是不变式检查、测试、快照 golden 文件和崩溃恢复。 +会话日志存储了 `step/start` 和 `step/end` 事件,尽管每个步骤作用域的事件本身已经携带 `{ turn, step }`:assistant 分片、assistant 消息、工具调用、工具结果、用量和错误。`deriveMessages()` 忽略步骤边界,ACP(Agent Client Protocol)在 UI 层面也忽略它们,主要消费方是不变式检查、测试、快照预期输出和崩溃恢复。 被否决的论点是:边界事件使日志更像仪式而非信息。实际上,`step/end` 是具体信息:读者无需从下一个事件推导状态,就能判断一次模型请求是已完成、已崩溃还是正在修复。同样,一个孤立的 `step/start` 对于「模型请求已发起但在产生任何分片之前就失败了」的场景也有价值。 ## 提案 -将轮次作为唯一的持久化边界。从 `SessionEventMap` 中移除 `step/start` 和 `step/end`;在需要分组的事件上保留数值型 `step` 字段。agent loop(智能体循环)递增步骤计数器并以该编号记录步骤作用域的事件,但不再追加开/关边界事件。消费方通过共享 `(turn, step)` 的连续事件推断步骤分组。 +将轮次作为唯一的持久化边界。`step/start` 和 `step/end` 将从 `SessionEventMap` 中移除;在需要分组的事件上保留数值型 `step` 字段。agent loop(智能体循环)递增步骤计数器并以该编号记录步骤作用域的事件,但不再追加开/关边界事件。消费方通过共享 `(turn, step)` 的连续事件推断步骤分组。 不变式插件应当强制步骤作用域的事件在一个已打开的轮次内具有有效的正整数步骤编号,而非要求独立的边界记录包围它们。崩溃恢复不应合成 `step/end`;如果一个被中断的轮次被保留,修复路径仍然可以关闭该轮次而无需捏造步骤边界记录。 @@ -22,11 +22,11 @@ Status: rejected — `step/end` is the durable indication that a model step fini - agent loop 中不再有 `closeStep()` 终结路径。 - ACP 快照和持久化契约 fixture(测试前置数据)不再期望步骤边界行。 - `deriveMessages()` 和回放从步骤作用域的事件推导出相同的消息历史。 -- [事件分类体系文档](../../../architecture.md)将轮次描述为持久化边界,将步骤描述为步骤作用域记录上的一个字段。 +- [事件分类体系文档](../../../../docs/architecture.md)将轮次描述为持久化边界,将步骤描述为步骤作用域记录上的一个字段。 - 会话格式版本和已记录的 fixture 被刷新;按预发布格式策略,非当前版本的已存储日志被拒绝。 ## 放弃了什么 日志不再将「一次模型请求已发起但进程死亡前未产生任何事件」记录为持久化事实,也不再有显式的「此步骤已完成」标记。在会话日志仍是持久化回放与审计表面的当下,这一损失不可接受。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml index 3302d9bef1..c65ced3c8b 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-drop-unused-session-lineage.md: 5f76baf33fa50fc1aff9a0ab43262f063aa8e51b -2026-06-20-drop-unused-session-lineage.zh.md: 79decbb40d93f0798189d4a131197db132549f1c +2026-06-20-drop-unused-session-lineage.md: 605f1949999435b24404e0c5a72320416303ae52 +2026-06-20-drop-unused-session-lineage.zh.md: 5d37a287fe1cb2d3cf196a6779dc11702826fa4a diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md index 79decbb40d..5d37a287fe 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除未使用的会话血缘元数据 +# Agent Note: 移除未使用的会话血缘元数据 Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. @@ -12,7 +12,7 @@ Status: rejected — `parentSession` is part of the documented fork/sub-agent se ## 提案 -从 `SessionHeader` 中移除 `parentSession`,直到真正的 fork/恢复功能需要血缘信息时再引入。如果存在相应 API,fork 仍然可以用先前事件来初始化新会话,但持久化的父指针应当与读取它的功能和解释它的 UX 一同引入。 +移除 `parentSession`,使其不再属于 `SessionHeader`,直到真正的 fork/恢复功能需要血缘信息时再引入。如果存在相应 API,fork 仍然可以用先前事件来初始化新会话,但持久化的父指针应当与读取它的功能和解释它的 UX 一同引入。 如果血缘信息回归,届时再决定它应放在不可变 header 中、会话图索引中,还是作为一等事件。当前字段不应预先锁定那个设计。 @@ -28,4 +28,4 @@ Status: rejected — `parentSession` is part of the documented fork/sub-agent se 代码库失去了一个为未来 fork/subagent UX 预备的现成血缘钩子。这是有意为之。该字段在功能存在时很容易重新引入,而未发布的立场允许格式变更无需迁移。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml index adb3407f9f..1cea1f5177 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-fold-session-persistence-interface.md: 3e9bb277ccd0b6319081cd1aac289db764f41e58 -2026-06-20-fold-session-persistence-interface.zh.md: 13a8945fcaa5529f6d4dc156b0dc1016ab6da62d +2026-06-20-fold-session-persistence-interface.md: 895b868b2a80d8655284bae1364a85e19e174da7 +2026-06-20-fold-session-persistence-interface.zh.md: 12c710d2235ff25c2c961cfdac31c0c01c3afdda diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md index 13a8945fca..12c710d223 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -1,4 +1,4 @@ -# RFC: 将持久化接口合并进 dsh-session +# Agent Note: 将持久化接口合并进 dsh-session Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. @@ -28,4 +28,4 @@ Status: rejected — the separate persistence interface package is the intended `dsh-session` 变得更重:它同时拥有内存日志和持久化接口。这就是代价。如果第三方持久化后端已经形成公开生态,独立的接口包会是更清晰的 SDK 边界;但在预发布阶段,在尚无外部消费方时,多出的包看起来更像是过早的抽象。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml index 83244e5741..8001c0937f 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-generic-tool-rendering.md: 77d06968a24211835d1ff5db2541efdeb8227878 -2026-06-20-generic-tool-rendering.zh.md: ab032864a5304d6781d990d1f805fe9d280c1655 +2026-06-20-generic-tool-rendering.md: 6fc610546da04e7d1e16fc17ada87483a142aa3c +2026-06-20-generic-tool-rendering.zh.md: e19907b71dcb93d325f94695c05bda23fca254cc diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md index ab032864a5..e19907b71d 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -1,4 +1,4 @@ -# RFC: 收拢工具自有的 UI 展示逻辑 +# Agent Note: 收拢工具自有的 UI 展示逻辑 Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. @@ -24,7 +24,7 @@ Status: rejected — tool-owned presentation should wait for more real tools bef - `ToolCallPresentation`、`ToolResultPresentation`、`ToolTerminal` 和 `ToolCallKind` 消失,除非一个最小的通用 UI 类型仍需要其中之一。 - ACP 不再维护 presenter pending 状态,也不再在实时流式输出/加载回放期间调用工具回调。 - `dsh-tool-bash` 不再解析渲染文本来恢复退出状态以供 UI pill 使用。 -- 快照 golden 文件展示通用工具卡片和文本结果。 +- 快照预期输出展示通用工具卡片和文本结果。 ## 放弃了什么 @@ -32,4 +32,4 @@ Bash 失去其自定义的终端风格卡片和模型生成描述的放置位置 ## 相关 -这是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 RFC 被接受,那个更窄的 RFC 就不再必要。 +这是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 Agent Note(agent 决策记录)被接受,那个更窄的 Agent Note 就不再必要。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml index 96425f0390..73df65d34f 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-retire-mid-turn-steering.md: 2c4d686942d2bfa8016bb60d55624dc59a933c1a -2026-06-20-retire-mid-turn-steering.zh.md: 2196d7d1d1f1bff39e8e0f03cf9910ab41434d2a +2026-06-20-retire-mid-turn-steering.md: a8812b3222739244d77f4d4dab60cf7c0cd6907d +2026-06-20-retire-mid-turn-steering.zh.md: 6beb00a646fd16b7bd9d987fb28429771145d6fc diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md index 2196d7d1d1..6beb00a646 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md @@ -1,4 +1,4 @@ -# RFC: 移除轮次中途引导 +# Agent Note: 移除轮次中途引导 Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. @@ -14,7 +14,7 @@ agent(智能体)暴露了两条用户消息路径,外观相近但生命周 暂时删除轮次中途的用户 steering。`Agent.send()` 成为提交用户内容的唯一公开方式;当 agent 正在运行时,内容等待下一个轮次。循环仅因工具调用而在轮次内继续,不因用户在某个步骤运行期间输入内容而继续。调用方若要中断当前轮次,使用 `cancel()` 后再 `send()`。 -移除 `Agent.steer()`、steering FIFO、`steering/message`、`agent/steering`、由 steering 驱动的续行逻辑,以及取消操作中区分排队消息与 steering 消息的逻辑。除非实现 PR 发现了生产级监听器,否则在同一变更中一并移除 `agent/turn-continuation`;没有 steering 后,当前仓库不再有具体的续行消费方。如果将来真正的预算或目标插件需要强制续行,应以该插件为具体消费方重新引入一个更窄的 seam。 +移除 `Agent.steer()`、steering FIFO、`steering/message`、`agent/steering`、由 steering 驱动的续行逻辑,以及取消操作中区分排队消息与 steering 消息的逻辑。除非实现 PR(Pull Request)发现了生产级监听器,否则在同一变更中一并移除 `agent/turn-continuation`;没有 steering 后,当前仓库不再有具体的续行消费方。如果将来真正的预算或目标插件需要强制续行,应以该插件为具体消费方重新引入一个更窄的 seam。 ## 验收标准 @@ -34,4 +34,4 @@ agent(智能体)暴露了两条用户消息路径,外观相近但生命周 本提案与[移除持久化步骤边界](2026-06-20-drop-durable-step-boundaries.md)天然配对,因为移除同轮次 steering 和 `agent/turn-continuation` 后,工具调用成为一个轮次包含多个模型步骤的唯一原因。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml index b3e62c79cd..c4b40325a3 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-single-session-acp-bridge.md: 8aa8f5d605154f697086dd5d432bbe9bd79c5dcd -2026-06-20-single-session-acp-bridge.zh.md: a056bc5acfab9d48259670170a964e8358df866c +2026-06-20-single-session-acp-bridge.md: e99de76854390a0979d1b66866d1d48aacbc0036 +2026-06-20-single-session-acp-bridge.zh.md: 1db5484c014178dfedceab20221a3d317877c35f diff --git a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md index a056bc5acf..1db5484c01 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md @@ -1,4 +1,4 @@ -# RFC: 将 ACP 桥接恢复为每连接一个活跃会话 +# Agent Note: 将 ACP 桥接恢复为每连接一个活跃会话 Status: rejected — Zed is the current target ACP client and its ACP implementation is explicitly multi-session: it stores live sessions in a `HashMap<SessionId, AcpSession>`, tracks `pending_sessions`, joins concurrent loads for the same id, and tests close-during-load behavior. @@ -6,7 +6,7 @@ Status: rejected — Zed is the current target ACP client and its ACP implementa ## 问题 -ACP(Agent Client Protocol)桥接现在支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向会话/agent(智能体)查找、逐会话的 prompt 状态、加载中 id、每条事件的解复用、跨会话拆除,以及未来权限提示与后台任务的隔离问题。较早的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在追踪未完成的权限归属部分;本 RFC 是与之竞争的简化路径。 +ACP(Agent Client Protocol)桥接现在支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向会话/agent(智能体)查找、逐会话的提示词状态、加载中 id、每条事件的解复用、跨会话拆除,以及未来权限提示与后台任务的隔离问题。较早的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在追踪未完成的权限归属部分;本 Agent Note(agent 决策记录)是与之竞争的简化路径。 产品目标已经证明它需要在一个 harness 进程上承载并发的编辑器对话:Zed 的 ACP 连接拥有多个会话和加载状态。快照回放层仍然避免并发模型流,因为其回放条目是位置相关的;这是测试 fixture(测试前置数据)的局限,而非移除桥接多路复用的理由。 @@ -22,10 +22,10 @@ ACP(Agent Client Protocol)桥接现在支持在一条 JSON-RPC 连接上承 - 当该记录存在时,`session/new` 和 `session/load` 拒绝请求。 - 事件处理器不再在 `Map<sessionId, record>` 上做解复用。 - 多会话测试被移除,或移至继续支持多路复用的提案下。 -- 既有的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)更新为链接本 RFC,并继续作为当前方向。 +- 既有的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)更新为链接本 Agent Note,并继续作为当前方向。 ## 放弃了什么 ACP 客户端无法在一个服务器进程上承载多个并发对话。这是一项有实质意义的能力削减。对于一个尚未发布的 harness 而言,更简单的模型仍然合理:一个编辑器对话对应一个 agent 进程,跨会话的权限/后台任务隔离不再是活跃的正确性负担。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index 1c341c252c..eae5710a9a 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-truncate-interrupted-turns.md: dd8475771fcd9fdd0910bd37480a50679e87911c -2026-06-20-truncate-interrupted-turns.zh.md: 7fcd7292c8c53ebf4d784cdb79a1be58960f763c +2026-06-20-truncate-interrupted-turns.md: af18618ad4c41af125e37c51b9fd971dd8eae64e +2026-06-20-truncate-interrupted-turns.zh.md: 4deebfcc7417dd860c655813230d3a79b2d98c50 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index 7fcd7292c8..4deebfcc74 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -1,4 +1,4 @@ -# RFC: 加载时截断被中断的最终轮次 +# Agent Note: 加载时截断被中断的最终轮次 Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. @@ -8,13 +8,13 @@ Status: rejected — a single turn can contain substantial real work, including 当前的持久化契约会保留已持久写入但从未关闭的最终轮次。加载时,`interruptedTurnClosers()` 扫描尾部,为未应答的工具调用合成 error `tool/result` 事件,在 step 处于打开状态时追加 `step/end`,追加 `turn/end { kind: 'interrupted' }`,并要求后端持久提交这次修复。协调器、JSONL 后端、SQLite 后端、会话事件词汇、不变式、文档和测试都对这条合成关闭路径进行了建模。 -这是一套庞大的机制,只为保留上次崩溃轮次中的部分工作。它还会凭空创造从未发生过的事件。合成的工具结果虽然有用(因为它使 provider 历史保持合法),但也意味着恢复后的日志中包含了模型可见、却并非任何工具产出的文本。当前设计在尚无已发布产品、也没有真实恢复 UX 来证明部分轮次恢复确有价值的情况下,就优化了最大化尾部保留。 +这是一套庞大的机制,只为保留上次崩溃轮次中的部分工作。它还会凭空创造从未发生过的事件。合成的工具结果虽然有用(因为它使提供方历史保持合法),但也意味着恢复后的日志中包含了模型可见、却并非任何工具产出的文本。当前设计在尚无已发布产品、也没有真实恢复 UX 来证明部分轮次恢复确有价值的情况下,就优化了最大化尾部保留。 ## 提案 加载时只保留最后一个已完成的轮次。后端仍然容忍并截断撕裂的最终记录,但如果解析出的持久前缀止于一个打开的 `turn/start` 之后,规范的修复方式是丢弃上一个 `turn/end` 之后的所有事件。不合成 `tool/result`,不合成 `step/end`,不追加 `turn/end { interrupted }`,也不引入 `interrupted` 轮次结束原因。 -这使持久化的轮次边界变得简单:一个已完成的 `turn/end` 就是检查点。最后一个检查点之后的内容都是崩溃尾部。下一次 prompt 从最后一个已知合法的 provider transcript(文本记录)恢复,而不是从部分重建的最终轮次恢复。 +这使持久化的轮次边界变得简单:一个已完成的 `turn/end` 就是检查点。最后一个检查点之后的内容都是崩溃尾部。下一次提示词从最后一个已知合法的提供方 transcript(文本记录)恢复,而不是从部分重建的最终轮次恢复。 ## 验收标准 @@ -33,4 +33,4 @@ Status: rejected — a single turn can contain substantial real work, including 本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化 step 边界事件的大部分动机,使[移除持久化 step 边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 -<!-- rfc-format: alternatives-not-recorded (pre-format RFC) --> +<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index cd0677dac6..fa0ab68ede 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-unimplemented-subagent-vocabulary.md: 1621bc1feee8bf98478242f002d9d1dca16878f5 -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: b26ffee4764cd5a5937ef0436fa30c7c0eeba87a +2026-07-04-prune-unimplemented-subagent-vocabulary.md: f99a33163b48735f9634b5bb3dcac5c24eb893f8 +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: a1a2f35ad31a0c40d82cff00df582a488619c241 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 582673f185..f99a33163b 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -2,6 +2,8 @@ Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below records the decision-time state. +English | [中文](2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md) + ## Problem The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) shipped a two-tier capability design: start-time capability flags checked by the service, and optional runtime methods on `SubagentRun`. Three start-time features and both optional runtime methods have zero implementations and zero callers: diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index b26ffee476..a1a2f35ad3 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -1,6 +1,6 @@ -# RFC: 裁剪未实现的 subagent seam 词汇 +# Agent Note: 裁剪未实现的 subagent seam 词汇 -Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. +Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below records the decision-time state. [English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 @@ -8,18 +8,18 @@ Status: rejected — the deferred capability vocabulary (`outputSchema`/`structu [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:启动时由服务检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三个启动时特性和两个可选运行时方法的实现数与调用数均为零: -- **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅由测试 mock(`packages/support/subagent-mock`)为其自身 spec 产出。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 +- **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):在作出决策时,每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅出现在脚本化测试 fixture(测试前置数据)中。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 - **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——包括 mock 也没有;spawn spec 断言的正是它们的*缺失*。 `dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 `SchemaSpec` 类型。三个后续 subagent 工作流(per-session 快照回放、fork seed 边界、ACP(Agent Client Protocol) 后端)都围绕这块接口面落地,却没有增长出哪怕一个消费方。 ## 提案 -从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 与 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、mock 的 structured 分支及其 `capabilities`/`structured` 配置项,以及为固定被移除接口面而存在的测试(两行拒绝测试、spawn 缺失测试、mock structured spec)。从 `packages/subagent/subagent/package.json` 中删除 `dsh-tools` 的 peer/dev 依赖。更新 [subagent.md](../../../core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest(元数据清单),以及 `packages/subagent/subagent`、`packages/subagent/subagent-spawn`、`packages/subagent/subagent-fork` 和 `packages/support/subagent-mock` 的 README 相关行。实现 PR(Pull Request)按照 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam RFC 的能力目录。 +从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 与 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、脚本化 fixture 的 structured 分支和能力旋钮,以及为固定被移除接口面而存在的测试。`dsh-tools` 的 peer/dev 依赖应从 `packages/subagent/subagent/package.json` 中删除。更新 [subagent.md](../../../../docs/core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest(元数据清单),以及受影响的提供方 README。实现 PR(Pull Request)按照 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam Agent Note(agent 决策记录)的能力目录。 -**保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的 tool 尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个 tool 默认值,而非删除正在工作的强制逻辑。 +**保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个工具默认值,而非删除正在工作的强制逻辑。 -审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 恰好记录了这种形态从 bash executor 中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) 的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 RFC 要裁剪的接口面。 +审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 恰好记录了这种形态从 bash executor 中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent-observe-enrich Agent Note](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) 的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 这是[从持久化 seam 裁剪死方法](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须为无人声明的成员,甚至更弱,因为这里连一个实现都没有。 @@ -27,13 +27,13 @@ Status: rejected — the deferred capability vocabulary (`outputSchema`/`structu ### 为什么不保留? -两类能力的设计是 seam RFC 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 RFC 作为记录仍然成立;而且 seam RFC 本身承认已交付的 `toolFilter` 形态是错误的(真正的强制需要在子 agent 上下文中实施 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此基于真实实现提供方重新添加时,将固定出一份比当前推测性契约更好的契约。 +两类能力的设计是 seam Agent Note 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 Agent Note 作为记录仍然成立;而且 seam Agent Note 本身承认已交付的 `toolFilter` 形态是错误的(真正的强制需要在子 agent 上下文中实施 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此基于真实实现提供方重新添加时,将固定出一份比当前推测性契约更好的契约。 ## 验收标准 -- 被移除的拼写仅出现在本 RFC 和修订后的 seam RFC 中;`SubagentCapabilities` 为 `{ depthLimit: boolean }`;`dsh-tools` 依赖边已消除(`hygiene` 绿色)。 +- 被移除的拼写仅出现在本 Agent Note 和修订后的 seam Agent Note 中;`SubagentCapabilities` 为 `{ depthLimit: boolean }`;`dsh-tools` 依赖边已消除(`hygiene` 绿色)。 - 深度强制测试不变且绿色。 ## 风险 -subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 RFC 缩减的 seam 词汇范围内;observe-enrich RFC 记录了因缺少消费方而删除 `agentType` 兄弟字段的判断,本 RFC 延续了这一判断。CC hooks 桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不涉及本文移除的任何接口面;observe-enrich RFC 推迟的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 RFC 模式所预期的重新添加触发点。 +subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 Agent Note 缩减的 seam 词汇范围内;observe-enrich Agent Note 记录了因缺少消费方而删除 `agentType` 兄弟字段的判断,本 Agent Note 延续了这一判断。CC hooks 桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不涉及本文移除的任何接口面;observe-enrich Agent Note 推迟的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 Agent Note 模式所预期的重新添加触发点。 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index b6d3903ddd..34f8bdb0c9 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-collapse-workflow-to-foreground-core.md: eaf8a4a22766e06b743a7b91d2a607eb6c8e67d9 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 4b1f4ebbec852386ca4577a38a9bd41b7529e500 +2026-07-12-collapse-workflow-to-foreground-core.md: 629e2140523c3ae7caf533de99821206d05f1b8e +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 3cdaeebe42ca799a48c5321b70c07e6151d8f98d diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 4b1f4ebbec..3cdaeebe42 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -1,4 +1,4 @@ -# RFC: 将工作流收缩至已使用的前台核心 +# Agent Note: 将工作流收缩至已使用的前台核心 Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. @@ -6,7 +6,7 @@ Status: rejected — Workflow progress is an intentional observation surface; ma ## 问题 -工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 +工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体) outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent(智能体)、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 @@ -20,7 +20,7 @@ live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.i 保留已使用的核心:`agent(prompt, { schema, model })`、`parallel`、`pipeline`、`args`、并发/agent 上限、取消、有界 dispose(资源释放)、结构化结果、worker 隔离与前台工具收集。移除所有 `workflow/*` 事件及其仅供事件使用的 info/outcome 类型;移除 `phase()`、`log()`、agent 的 `label`/`phase`、phase 声明、`whenToUse` 及其 worker 消息/host 观测者;将工作流元数据收缩为工具实际使用的 name;移除仅供事件使用的 run id/meta 快照与合成的 agent-end 账本。将 `WorkflowRun` 收缩为 `result`、`cancel()` 和 `dispose()`;工具渲染请求方持有的 name。移除 `WorkflowStartRequest.signal` 及 worker host 的 input-signal listener/disarm 状态,保留调用方从其 abort signal 到 `run.cancel()` 的桥接。将 `WorkflowError` 变为单一的 fatal 错误类,不再有布尔模式或 `isFatalWorkflowError()` 辅助函数。 -修订已实施的 dynamic-workflow RFC,并更新 seam/tool/worker README、工具 schema、生成的 catalog 与 package 依赖图、worker type-equiv 记录、单元测试以及工作流快照/header fixture(测试前置数据)。如果进度 UI 工作被立项,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活这套协议。 +修订已实施的 dynamic-workflow Agent Note(agent 决策记录),并更新 seam/工具/worker README、工具 schema、生成的 catalog 与包(package)依赖图、worker type-equiv 记录、单元测试以及工作流快照/header fixture(测试前置数据)。如果进度 UI 工作被立项,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活这套协议。 ## 曾考虑的替代方案 @@ -32,7 +32,7 @@ live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.i - 不再保留任何工作流事件、phase/log 协议消息、run-id 生成器、仅供进度使用的元数据、host 配对账本或 fatal 模式分支。 - run handle 不再有 id/meta 回显,取消在同步 `start()` 返回后只有一条持有者拥有的通道。 - parallel/pipeline 行为、上限、取消静默、worker 隔离、结构化输出与面向模型的工作流场景保持测试覆盖。 -- 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 +- 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建与 hygiene 全部通过。 ## 风险 diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml index 0c7d9feb75..d1a52c0efb 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-12-prune-unused-skill-registry-surface.md: 5b90deca8681373b2cc2befa3ab341084f924a3d -2026-07-12-prune-unused-skill-registry-surface.zh.md: 7b0e24f49688ed61f2ac4bff93b4c3f85117a170 +2026-07-12-prune-unused-skill-registry-surface.md: 5a13effa04a6cd9954741a0a33ebc6fc3512fab8 +2026-07-12-prune-unused-skill-registry-surface.zh.md: 4b25c03d740f4fda1696629d56b88057a055f0af diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md index 7b0e24f496..4b25c03d74 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md @@ -1,4 +1,4 @@ -# RFC: 裁剪 skill 注册表中未使用的接口 +# Agent Note: 裁剪 skill 注册表中未使用的接口 Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. @@ -10,13 +10,13 @@ skill(技能)服务的嵌入式运行时子系统中,`ctx.skills.register( ## 提案 -移除 `SkillService.register()`、`SkillRegistration`、运行时伪提供方及保留名称规则、运行时 revision/缓存分支,以及仅用于运行时的 source/rank 规范化逻辑。需要嵌入式 skill 的测试改为注册一个小型真实提供方。保留 `providerRevision` 作为进行中的发现 epoch,但已完成的目录缓存仅以 cwd 为键:每次提供方变更同步清除缓存,await 之后的 revision 比较已能阻止插入陈旧结果。从 skill 契约和 local-provider 副本中移除 `whenToUse`、`SkillCandidate.path` 与 `SkillDefinition.path`,同时保留提供方的 locator/root 路径;保留 `metadata`、`disableModelInvocation`、`source`、`provider`、`locator` 和 `resourceBase`,因为它们要么是有意开放的扩展词汇,要么是生产消费的字段。 +移除 `SkillService.register()`、`SkillRegistration`、运行时伪提供方及保留名称规则、运行时 revision/缓存分支,以及仅用于运行时的 source/rank 规范化逻辑。需要嵌入式 skill 的测试改为注册一个小型真实提供方。保留 `providerRevision` 作为进行中的发现 epoch,但已完成的目录缓存仅以 cwd 为键:每次提供方变更同步清除缓存,await 之后的 revision 比较已能阻止插入陈旧结果。从 skill 契约和本地提供方副本中移除 `whenToUse`、`SkillCandidate.path` 与 `SkillDefinition.path`,同时保留提供方的 locator/root 路径;保留 `metadata`、`disableModelInvocation`、`source`、`provider`、`locator` 和 `resourceBase`,因为它们要么是有意开放的扩展词汇,要么是生产消费的字段。 -同步修订 skill 系统 RFC、README、JSDoc、目录文件与测试。agent(智能体)作用域的系统提示词段、工具提供方和变量明确不在本提案范围内:[agent 作用域贡献者契约](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)有意允许在 `setup(agentCtx)` 期间通过 agent 拥有的上下文注册这三者,因此仓库内没有固定的作用域注册并不能证明它们未被使用。 +同步修订 skill 系统 Agent Note(agent 决策记录)、README、JSDoc、目录文件与测试。agent(智能体)作用域的系统提示词段、工具提供方和变量明确不在本提案范围内:[agent 作用域贡献者契约](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)有意允许在 `setup(agentCtx)` 期间通过 agent 拥有的上下文注册这三者,因此仓库内没有固定的作用域注册并不能证明它们未被使用。 ## 曾考虑的替代方案 -**保留面向嵌入方的运行时 skill 注册。** 这是已实现的 skill RFC 中有意提供的同步直接定义便利接口。一个小型提供方包装层可以在 effect 拥有的生命周期下暴露相同的嵌入数据,但它必须实现异步 `list()`/`get()`、携带提供方身份,并接受提供方的重复语义。本提案选择只保留一条统一的提供方路径,而非维护第二套排序、校验、缓存失效与查找路径。 +**保留面向嵌入方的运行时 skill 注册。** 这是已实现的 skill Agent Note 中有意提供的同步直接定义便利接口。一个小型提供方包装层可以在 effect 拥有的生命周期下暴露相同的嵌入数据,但它必须实现异步 `list()`/`get()`、携带提供方身份,并接受提供方的重复语义。本提案选择只保留一条统一的提供方路径,而非维护第二套排序、校验、缓存失效与查找路径。 ## 验收标准 From c715612928f69a52b0eb90d2002c3f14fb9b9290 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:22:36 +0800 Subject: [PATCH 017/113] docs(i18n): sync compaction checkpoint translation --- .../2026-06-18-compaction-capability-seam.i18n.yaml | 4 ++-- .../2026-06-18-compaction-capability-seam.zh.md | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 4edec28660..56723c5c4c 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-compaction-capability-seam.md: 07da796fdabffb8a43950ad501f5623d4474923a -2026-06-18-compaction-capability-seam.zh.md: 18ef9a8d833bdaa924324867d522399c0ad48243 +2026-06-18-compaction-capability-seam.md: a263b5e7d0245bd1279024a50e05b2f33edad521 +2026-06-18-compaction-capability-seam.zh.md: 79c883c364c9045fa2f85e7743091f83f0553783 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 18ef9a8d83..79c883c364 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -18,7 +18,7 @@ Status: implemented 遵循[能力 seam Agent Note(agent 决策记录)](../architecture/2026-06-13-capability-seams.md),压缩以独立包(package)发布,使契约、算法和(后续的)消费方 surface 各自独立演进: -1. **接口** — `@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇以及 `compact/*` 会话事件。它将 `compactIfNeeded()` 和 `compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。 +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 先稳定下来。 @@ -71,13 +71,14 @@ retry → next numbered step/start ⟵ derives from the replacement surface ### Surface 替换:`compact/*` 事件仅存在于日志;一条 `user/message` 承载摘要 -由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `surfaceOp: { op: 'replace', start, end }`,其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的条目*和*簿记事件。`compact/*` 事件是纯日志记录(锁 + 溯源信息)。surface 变更位于锁**内部**,`compact/end` 是最后追加的事件: +由于 `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 → surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). +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). ``` @@ -86,7 +87,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ### 检查点框架 + 增量合并(后端私有) -基础后端将摘要包装为已建立的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 仅承诺一条替换 user 消息承载可能带框架的摘要。 +基础后端将摘要包装为已建立的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略;seam 承诺由一条替换 user 消息承载可能带框架的摘要,并使用规范的检查点来源。 ### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败的分类 @@ -119,7 +120,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **包**:`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`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 -- **`dsh-compact`** 拥有 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`;这些带缓存的 surface 边缘检查由 `compactRegion` 和 `compactIfNeeded` 用来避免拆分工具调用/结果对。缓存按 seq 校验当前成员关系,并从每个切割点的一条平衡序列回答两侧边缘;陈旧或缺失的 seq 以及孤立结果都会被拒绝。 +- **`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`;服务级默认值使组合无需重复数值策略即可使用。 From 441a17d62eea9ec1d0f556f9aea0eae23af88d1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:39:55 +0800 Subject: [PATCH 018/113] fix(ci): ignore translation records in Cordis config scan The Loader verifier matched docs/cordis-primer.i18n.yaml solely because its basename contained cordis. Centralize file discovery, exclude i18n sidecars, and cover Loader YAML plus excluded trees with a regression test. --- scripts/cordis-config-files.spec.ts | 36 +++++++++++++++++++++++++++++ scripts/cordis-config-files.ts | 18 +++++++++++++++ scripts/verify-cordis-config.ts | 6 ++--- 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 scripts/cordis-config-files.spec.ts create mode 100644 scripts/cordis-config-files.ts diff --git a/scripts/cordis-config-files.spec.ts b/scripts/cordis-config-files.spec.ts new file mode 100644 index 0000000000..49da2c6aaf --- /dev/null +++ b/scripts/cordis-config-files.spec.ts @@ -0,0 +1,36 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { cordisConfigFiles } from './cordis-config-files.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('cordisConfigFiles', () => { + it('finds Loader YAML without treating translation records as configs', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-cordis-config-files-')) + roots.push(root) + for (const directory of ['.claude', 'docs', 'examples', 'node_modules/pkg', 'vendor/pkg']) { + mkdirSync(join(root, directory), { recursive: true }) + } + for (const file of [ + '.claude/hidden.cordis.yml', + 'docs/cordis-primer.i18n.yaml', + 'examples/agent.cordis.yaml', + 'examples/headless.cordis.yml', + 'node_modules/pkg/hidden.cordis.yml', + 'vendor/pkg/hidden.cordis.yml', + ]) { + writeFileSync(join(root, file), '[]\n') + } + + expect(cordisConfigFiles(root)).toEqual([ + 'examples/agent.cordis.yaml', + 'examples/headless.cordis.yml', + ]) + }) +}) diff --git a/scripts/cordis-config-files.ts b/scripts/cordis-config-files.ts new file mode 100644 index 0000000000..9473779efe --- /dev/null +++ b/scripts/cordis-config-files.ts @@ -0,0 +1,18 @@ +/** Cordis Loader configuration file discovery. */ + +import { globSync } from 'node:fs' + +/** + * Return repository-relative Cordis Loader YAML paths under `root`. + * + * Translation consistency records are YAML sidecars, never Loader inputs. + * + * @param root Repository root to scan. + * @returns Sorted repository-relative Loader configuration paths. + */ +export function cordisConfigFiles(root: string): string[] { + return globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], { + cwd: root, + exclude: ['.claude/**', 'node_modules/**', 'vendor/**', '**/*.i18n.yaml'], + }).sort() +} diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index c9820a67a8..fafc147ce9 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -12,6 +12,7 @@ import { globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' import * as yaml from 'js-yaml' import ts from 'typescript' +import { cordisConfigFiles } from './cordis-config-files.ts' interface JsExpr { __jsExpr: string @@ -39,10 +40,7 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const schema = yaml.JSON_SCHEMA.extend(jsExprType) -const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], { - cwd: root, - exclude: ['.claude/**', 'node_modules/**', 'vendor/**'], -}).sort() +const files = cordisConfigFiles(root) const errors: string[] = [] const examplePluginReferences: PluginReference[] = [] From d59af9befc69029db81c5d9bba836931c347ce97 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:05:50 +0800 Subject: [PATCH 019/113] docs(i18n): address core-data review feedback --- docs/core-data-structures/approval.i18n.yaml | 2 +- docs/core-data-structures/approval.zh.md | 2 +- docs/core-data-structures/bash.i18n.yaml | 2 +- docs/core-data-structures/bash.zh.md | 8 ++--- .../core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 4 +-- docs/core-data-structures/core.i18n.yaml | 4 +-- docs/core-data-structures/core.md | 2 ++ docs/core-data-structures/core.zh.md | 34 +++++++++++-------- .../core-data-structures/filesystem.i18n.yaml | 2 +- docs/core-data-structures/filesystem.zh.md | 6 ++-- .../llm-streaming.i18n.yaml | 2 +- docs/core-data-structures/llm-streaming.zh.md | 14 ++++---- .../persistence.i18n.yaml | 2 +- docs/core-data-structures/persistence.zh.md | 10 +++--- docs/core-data-structures/sandbox.i18n.yaml | 2 +- docs/core-data-structures/sandbox.zh.md | 6 ++-- docs/core-data-structures/scope.i18n.yaml | 2 +- docs/core-data-structures/scope.zh.md | 4 +-- .../session-query.i18n.yaml | 2 +- docs/core-data-structures/session-query.zh.md | 6 ++-- docs/core-data-structures/session.i18n.yaml | 2 +- docs/core-data-structures/session.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 2 +- docs/core-data-structures/subagent.zh.md | 6 ++-- .../system-prompt.i18n.yaml | 2 +- docs/core-data-structures/system-prompt.zh.md | 4 +-- docs/core-data-structures/tools.i18n.yaml | 2 +- docs/core-data-structures/tools.zh.md | 2 +- docs/core-data-structures/web.i18n.yaml | 2 +- docs/core-data-structures/web.zh.md | 2 +- docs/core-data-structures/workflow.i18n.yaml | 2 +- docs/core-data-structures/workflow.zh.md | 4 +-- ...-acp-default-export-drops-inject.i18n.yaml | 2 +- ...0001-acp-default-export-drops-inject.zh.md | 4 +-- ...ession-disabled-filesystem-tools.i18n.yaml | 2 +- ...expression-disabled-filesystem-tools.zh.md | 2 +- 37 files changed, 83 insertions(+), 77 deletions(-) diff --git a/docs/core-data-structures/approval.i18n.yaml b/docs/core-data-structures/approval.i18n.yaml index c6bfeafaf9..583f1cd542 100644 --- a/docs/core-data-structures/approval.i18n.yaml +++ b/docs/core-data-structures/approval.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write approval.md: c9b411e38508fc7e8ac2e2dd923d9c8cc2f475a3 -approval.zh.md: ee593298fece8b2782228f2e93c5509871e15166 +approval.zh.md: 54985cd32f6b16ef90a1ddd1f6da4b87a261788d diff --git a/docs/core-data-structures/approval.zh.md b/docs/core-data-structures/approval.zh.md index ee593298fe..54985cd32f 100644 --- a/docs/core-data-structures/approval.zh.md +++ b/docs/core-data-structures/approval.zh.md @@ -8,7 +8,7 @@ ## 标识与结果 -每个请求都会获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent/session id 互换。 +每个请求都会获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent/会话 id 互换。 ```ts type-equiv /** diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 168c96a10a..55ea9b35e9 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write bash.md: 35cf2061588907dde41123efb01e453eb9cc929d -bash.zh.md: 56e5ae7b6231575a6b591093724d5560ade27056 +bash.zh.md: 2b388bd51047219ed46faa07313bb84da1dbb080 diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 56e5ae7b62..2b388bd510 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -161,9 +161,9 @@ interface CollectedOutput { ## 文件沙箱:`BashSandboxInfo` -消费 sandbox 的执行器通过 `BashExecutor.sandboxMode` 暴露其已配置的模式回退值。工具层请求 [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md),把每个调用会话的持久 `sandbox/mode` 覆盖值与不可变 cwd 解析为 `BashExecRequest.sandboxPolicy`;经用户批准、严格更宽松的调用只替换模式。模式/root/enforcement 词汇归 [`@deepseek-ai/dsh-sandbox` seam](sandbox.md) 所有;模式仅管辖文件效果。 +使用沙箱的执行器通过 `BashExecutor.sandboxMode` 暴露其已配置的模式回退值。工具层请求 [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md),把每个调用会话的持久 `sandbox/mode` 覆盖值与不可变 cwd 解析为 `BashExecRequest.sandboxPolicy`;经用户批准、严格更宽松的调用只替换模式。模式/root/enforcement 词汇归 [`@deepseek-ai/dsh-sandbox` 沙箱 seam](sandbox.md) 所有;模式仅管辖文件效果。 -sandbox 化运行会报告其模式、保守的拒绝分类与强制执行完整度。`runnerFailed` 标记命令运行前 sandbox runner 已失败;前台执行会抛出 `SANDBOX_UNAVAILABLE`,而已结束的后台进程只能通过其事实通道报告。 +沙箱化运行会报告其模式、保守的拒绝分类与强制执行完整度。`runnerFailed` 标记命令运行前沙箱 runner 已失败;前台执行会抛出 `SANDBOX_UNAVAILABLE`,而已结束的后台进程只能通过其事实通道报告。 ```ts type-equiv /** @@ -183,11 +183,11 @@ interface BashSandboxInfo { } ``` -最后一项补全了这套词汇:当受限模式没有可用后端时,`ctx.sandbox` 提供方会抛出、执行器会传播由 [sandbox seam](sandbox.md) 所有的 `SANDBOX_UNAVAILABLE` 错误码。选定的 runner 拒绝其 profile 时会触达同一个故障关闭的前台错误;已结束的后台任务则记录 `runnerFailed`。模型会在结果中收到拒绝/runner 事实,仅当拒绝标记指出生效模式时才得知该模式,并可通过 `sandbox_permissions` 加 `justification` 请求一次性、严格更宽松的重试;执行任何操作前,`ctx.approval` 必须批准该次确切调用。完整的策略与切换设计见 [sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +最后一项补全了这套词汇:当受限模式没有可用后端时,`ctx.sandbox` 提供方会抛出、执行器会传播由[沙箱 seam](sandbox.md)所有的 `SANDBOX_UNAVAILABLE` 错误码。选定的 runner 拒绝其 profile 时会触达同一个故障关闭的前台错误;已结束的后台任务则记录 `runnerFailed`。模型会在结果中收到拒绝/runner 事实,仅当拒绝标记指出生效模式时才得知该模式,并可通过 `sandbox_permissions` 加 `justification` 请求一次性、严格更宽松的重试;执行任何操作前,`ctx.approval` 必须批准该次确切调用。完整的策略与切换设计见[沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 ## 后台进程:`BashProcess` -`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.tasks.start()` hooks;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时 resolve 且绝不 reject;进程结束后仍可读取,并且 sandbox 事实会在 `done` resolve 前写入。 +`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.tasks.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时 resolve 且绝不 reject;进程结束后仍可读取,并且沙箱事实会在 `done` resolve 前写入。 ```ts type-equiv /** diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 46ebcb6c85..5133356f6e 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: 173b7a7bb6e5e5a47922b0be8011e89a747c2771 +compaction.zh.md: 592e215e7efe69f0dd6099d3d510847cc732e641 diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 173b7a7bb6..592e215e7e 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -13,7 +13,7 @@ | 事件 | 载荷 | 作用 | |---|---|---| | `compact/start` | `{ turn }` | 获取日志记录的锁 | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance:摘要 block、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | +| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | | `compact/end` | `{ turn, error? }` | 释放锁(摘要调用抛出异常时设置 `error`) | 锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲 context 和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败 step 关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的 step 重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个 turn,因此一个过大 turn 中较早关闭的 step 可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败 step 关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的 step 重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个 turn,因此一个过大 turn 中较早关闭的 step 可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与孤立结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 1064347edd..0cb17efb9c 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -core.md: 9c6f1d88c67f60ea3c42ba2fcec07cb20ede4214 -core.zh.md: 2fee488e609166328b743e8ca035a9b207706f37 +core.md: 7d0f9503dfd4c47f185d2bcef84528145d66fa70 +core.zh.md: 4bc31681d96483a300cc7a0ccfb5e489ba34f691 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9c6f1d88c6..7d0f9503df 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -165,6 +165,8 @@ Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelit The full union, the adapter contract (usage-before-finish, raw-JSON tool arguments, the two sanctioned error paths), and `BlockAssembler` live on **[llm-streaming.md](llm-streaming.md)**. +<a id="the-model-request-and-result"></a> + ## The model request One model call is a fully-assembled `GenerateOptions`. The adapter answers with a raw `StreamChunk` stream; the consumer assembles it with `BlockAssembler` (see [llm-streaming.md](llm-streaming.md)). diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 2fee488e60..4bc31681d9 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -26,9 +26,9 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取与关系追踪 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | -| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、prompt 段落与协作式组装 | +| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | | [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | -| [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、provider API、错误分类体系 | +| [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | | [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 | @@ -39,11 +39,11 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [skills.md](skills.md) | skill 服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | | [compaction.md](compaction.md) | 压缩(compaction)seam:`compact/*` 会话事件、`CompactionResult`、`CompactService` 接口 | | [subagent.md](subagent.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | -| [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、provider 可用性、`WebError` | +| [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | | [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | -> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通 block 保留完整声明;`public-api` block 保留去除实现体的公开 class 声明。Cordis 服务使用生成的[服务目录](../cordis-catalog/services.md)。 +> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务使用生成的[服务目录](../cordis-catalog/services.md)。 ## `…Map → derived-union` 模式 @@ -92,7 +92,9 @@ declare module '@deepseek-ai/dsh-llm' { type Branded<B extends string> = string & { readonly [BRAND]: B } ``` -两个核心 ID 是 `CallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久 session 共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [tasks.md](tasks.md) 中的 `TaskId`。 +两个核心 ID 是 `CallId`(关联工具调用及其结果;dsh-llm)和 `SessionId`(活跃 agent 与持久会话共享的标识;dsh-session)。能力包也会品牌化各自的 id,例如 [tasks.md](tasks.md) 中的 `TaskId`。 + +<a id="content-blocks-and-messages"></a> ## 内容块与消息 @@ -115,7 +117,7 @@ interface ContentBlockMap { 各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`)、`ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持。 -`Message` 由角色和 block 组成。由循环派生的 assistant 消息携带其持久 provider/model 标识,以及可选的适配器私有回放元数据: +`Message` 由角色和块组成。由循环派生的 assistant 消息携带其持久提供方/模型标识,以及可选的适配器私有回放元数据: ```ts type-equiv /** Provider ownership and adapter-private replay data for an assistant message. */ @@ -165,6 +167,8 @@ interface MessageSourceMap { 完整联合类型、适配器契约(usage-before-finish、原始 JSON 工具参数、两条认可的错误路径)和 `BlockAssembler` 在 **[llm-streaming.md](llm-streaming.md)** 中。 +<a id="the-model-request-and-result"></a> + ## 模型请求 一次模型调用是一个完全组装好的 `GenerateOptions`。适配器以原始 `StreamChunk` 流作答;消费方用 `BlockAssembler` 组装它(见 [llm-streaming.md](llm-streaming.md))。 @@ -287,11 +291,11 @@ interface ToolSchema { ### 请求信封:`LlmCallConfig` 与记录的 header -循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的 prompt、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及 session prefix。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 `agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的 prompt 组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 +在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 @@ -353,11 +357,11 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及 turn enclosure 不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` checkpoint、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及 turn enclosure 不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## Agent 句柄 -`Agent` 是每个插件(UI、hook、orchestrator)面向编程的 surface。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。 +`Agent` 是每个插件(UI、钩子、orchestrator)面向编程的 surface。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -450,11 +454,11 @@ interface Agent { } ``` -`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越 turn 关闭、其持久化 checkpoint 以及连续的排队 turn;它不能证明某个 turn 仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越 turn 关闭、其持久化检查点以及连续的排队 turn;它不能证明某个 turn 仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`;该值在发布 `turn/end` 前退役。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、checkpoint 与 waterfall 契约。Turn 和 step 边界是持久 session 事件,而不是 agent emit。 +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。Turn 和 step 边界是持久会话事件,而不是 agent emit。 ## 发起 Agent @@ -462,7 +466,7 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把 ## 拦截决策 -每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex hook bridge 把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。Prompt 与工具后决策共享一种面向模型的 context 形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件 context 错标为用户 prompt)。其中的 `content` 作为 user-role 输入逐字到达模型,而 JSON `meta` 持久保存插件状态但不向模型暴露。未指定放置方式或指定为 `separate` 时,context 会成为 `context/message`;`prompt-prefix` 放置方式可用于 prompt 和 steering 收件箱附件,会在同一条消息中把 context 置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance、元数据与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 +每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享一种面向模型的上下文形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件上下文错标为用户提示词)。其中的 `content` 作为 user-role 输入逐字到达模型,而 JSON `meta` 持久保存插件状态但不向模型暴露。未指定放置方式或指定为 `separate` 时,上下文会成为 `context/message`;`prompt-prefix` 放置方式可用于提示词和 steering 收件箱附件,会在同一条消息中把上下文置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance、元数据与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -499,7 +503,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` 返回 `ContinuationDecision`(step 有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一 turn 中下一 step 的 steering,因此不携带 context 元数据——即类型化 `/goal` 模式): +`agent/turn-continuation` 返回 `ContinuationDecision`(step 有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一 turn 中下一 step 的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ @@ -522,7 +526,7 @@ type RequestError = Error & { code?: string } type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } ``` -`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲 context 与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在 session 日志中,而不是瞬态 payload 中。 +`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲上下文与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在会话日志中,而不是瞬态 payload 中。 `agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。 diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 69cb43d21d..4f17d4c4ed 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write filesystem.md: 3c9b041da92e71cd20429e8b1549c0b8e6f2436d -filesystem.zh.md: 11e386c98e671f4147419eec587399a5a3bb0d07 +filesystem.zh.md: 04d7c63da151f7198099486106ce2fb9d0dd1520 diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 11e386c98e..04d7c63da1 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -177,13 +177,13 @@ interface FsEditOutcome { ## fs 策略事件(提供方 seam 词汇) -`dsh-fs` 拥有三个事件,由工具分发、策略插件监听,使发射方(`dsh-tool-fs`)与监听方(`dsh-fs-policy`)共享词汇,而发射方无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/session 所有者结构。 +`dsh-fs` 拥有三个事件,由工具分发、策略插件监听,使发射方(`dsh-tool-fs`)与监听方(`dsh-fs-policy`)共享词汇,而发射方无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/会话所有者结构。 `fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不守卫该 emit——抛异常的监听方会在一次已成功的变更上表现为工具的 `isError` 结果。生成的目录在 [events.md](../cordis-catalog/events.md) 中展示确切签名。 ## 执行上下文(策略插件) -策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入 tool、agent 或 session 包。 +策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 ```ts type-equiv /** @@ -252,7 +252,7 @@ type FsErrorCode = | 'FS_ABORTED' ``` -目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行 sandbox 的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观察记录(或 `createIfAbsent` 遇到了现有文件)。`FS_STALE_VERSION` 表示后端版本不再与观察到的版本匹配(或编辑操作遇到缺失目标)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 +目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观察记录(或 `createIfAbsent` 遇到了现有文件)。`FS_STALE_VERSION` 表示后端版本不再与观察到的版本匹配(或编辑操作遇到缺失目标)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 ## 服务与插件 diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 22691c44f4..d22cd0c4a3 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb -llm-streaming.zh.md: 5dc270acc97cdeac8cf24a36c443d550e14122dc +llm-streaming.zh.md: 36fb640a030645861a163f6b33b3c0b60cf5ed8e diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 5dc270acc9..36fb640a03 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -35,7 +35,7 @@ type StreamChunk = ## `LlmFailure` -每个抛出的失败或 final-adapter 带内失败都会规范化为一种可序列化、提供方中立的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 +每个抛出的失败或最终适配器的带内失败都会规范化为一种可序列化、提供方中立的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 ```ts type-equiv /** Serializable provider-boundary facts; policy decides whether they are retryable. */ @@ -59,18 +59,18 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。final adapter 边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的 step,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为 turn 错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的 step,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为 turn 错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的 step;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 - **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方中立的内容与 provenance,不会收到私有状态。 -该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish-chunk 错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 +该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 ## `AppIdentity`:应用归属 -每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包 manifest 获取版本;每个字段都是公开产品事实——不含 secret、路径、session id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包 manifest 获取版本;每个字段都是公开产品事实——不含 secret、路径、会话 id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 ```ts type-equiv /** @@ -92,7 +92,7 @@ interface AppIdentity { ## `TokenUsage` -逐调用 token 记账。各计数**互不重叠**:`inputTokens` 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一 prompt 总数(如 DeepSeek 的 `prompt_tokens`),适配器会再将其扣除。`reasoningTokens` 存在时只是信息性细节,已经包含在 `outputTokens` 中;汇总时不得重复相加。 +逐调用 token 记账。各计数**互不重叠**:`inputTokens` 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一提示词总数(如 DeepSeek 的 `prompt_tokens`),适配器会再将其扣除。`reasoningTokens` 存在时只是信息性细节,已经包含在 `outputTokens` 中;汇总时不得重复相加。 ```ts type-equiv /** @@ -114,7 +114,7 @@ interface TokenUsage { ## `BlockAssembler` -`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,负责把 `StreamChunk` 流折叠回 `ContentBlock`、usage、finish reason 与 replay state。循环在记录原始 chunk 的同时,把同一批 chunk 送入 assembler,再将组装后的 assistant 内容连同其 provider/model provenance 一起存储。需要组装结果、又不想重新实现 fold 的消费方使用它。 +`BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,负责把 `StreamChunk` 流折叠回 `ContentBlock`、usage、结束原因与回放状态。循环在记录原始分片的同时,把同一批分片送入 assembler,再将组装后的 assistant 内容连同其提供方/模型 provenance 一起存储。需要组装结果、又不想重新实现 fold 的消费方使用它。 ```ts public-api /** @@ -156,7 +156,7 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的 chunk——block 重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容 block 与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 ```ts public-api /** diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index d8baef895b..76ccae0b33 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write persistence.md: d4bb4ed1b65eb74ff03483bf3a71a1736300834e -persistence.zh.md: 93a418b3b06dbf0d42978900ef79a0fb86ac15f7 +persistence.zh.md: ea0bff84bb939f0eab370375509e920ea45eb404 diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 93a418b3b0..ea0bff84bb 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -8,7 +8,7 @@ ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通 turn 的 checkpoint 后再领取下一个队列项;同步的 idle `inject()` 会调度自己的 checkpoint 而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭 turn 作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭 turn 之后的 session 事件——而后端会保留已缓冲事件供下次 flush 使用。 +`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通 turn 的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭 turn 作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭 turn 之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 ## 崩溃恢复保留被中断的轮次 @@ -16,7 +16,7 @@ ## `SessionLocation`——可选的逐会话制品目标 -`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各 session 共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前未 flush turn 的文件;它是位置提示,不是授权或新鲜度保证。 +`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前未 flush turn 的文件;它是位置提示,不是授权或新鲜度保证。 ```ts type-equiv /** @@ -73,7 +73,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化 session 时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -104,7 +104,7 @@ interface CreateSessionOptions { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个 session 一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、中断 turn 恢复以及读取/回放路径。 +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、中断 turn 恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 -共享同一磁盘 session 的多个后端通过[共享持久化写入协调器](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 +共享同一磁盘会话的多个后端通过[共享持久化写入协调器](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 1bd49e2273..675b9bfa10 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: dd02041dea998221f1fa7549cb829791137b5088 +sandbox.zh.md: 8db5520a125141cd043323ded4d26b92b81d1ea0 diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index dd02041dea..8db5520a12 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用 session 的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent 时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent 时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 ```ts type-equiv /** @@ -56,7 +56,7 @@ interface SandboxExecutionPolicy { } ``` -`ctx.sandboxPolicy.resolve()` 接收活跃 session;对于已批准的重试,还接收显式模式。该服务拥有优先级与 root 回退规则,使 bash 和 fs 不必重复实现。 +`ctx.sandboxPolicy.resolve()` 接收活跃会话;对于已批准的重试,还接收显式模式。该服务拥有优先级与 root 回退规则,使 bash 和 fs 不必重复实现。 ```ts type-equiv /** Inputs that select the sandbox policy for one capability call. */ @@ -68,7 +68,7 @@ interface SandboxPolicyRequest { } ``` -只有受约束的执行会到达 `ctx.sandbox`;其提供方策略在保留同一 root 的同时收窄模式。这使并发 session、消费方与一次性提权重试可以向同一提供方请求不同边界,而无需改变提供方状态。 +只有受约束的执行会到达 `ctx.sandbox`;其提供方策略在保留同一 root 的同时收窄模式。这使并发会话、消费方与一次性提权重试可以向同一提供方请求不同边界,而无需改变提供方状态。 ```ts type-equiv /** diff --git a/docs/core-data-structures/scope.i18n.yaml b/docs/core-data-structures/scope.i18n.yaml index 4067956fe6..488c1f88f1 100644 --- a/docs/core-data-structures/scope.i18n.yaml +++ b/docs/core-data-structures/scope.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write scope.md: 73a697f2843293daffff85dabf4656346f7dcd04 -scope.zh.md: b5cc21cfb3d3dbb3890d179f9a813dccb1c318ec +scope.zh.md: 5a40a3e964d2113752efba92beea4f4f333e94b3 diff --git a/docs/core-data-structures/scope.zh.md b/docs/core-data-structures/scope.zh.md index b5cc21cfb3..5a40a3e964 100644 --- a/docs/core-data-structures/scope.zh.md +++ b/docs/core-data-structures/scope.zh.md @@ -2,7 +2,7 @@ [English](scope.md) | 中文 -[scope 包](../../packages/core/scope)提供 identity、carrier 与 scoped-layer 词汇,使同一个注册 context 同时代表逐 agent 可见性和共享生命周期所有权。它是库原语,而不是 Cordis 服务;生命周期设计理由由 [agent-scope 运行时设计 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer)规定,registry-layer 决策由[共享存储 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)规定,可调用 API 与过滤语义则由包 [README](../../packages/core/scope/README.md)规定。 +[scope 包](../../packages/core/scope)提供 identity、carrier 与 scoped-layer 词汇,使同一个注册上下文同时代表逐 agent 可见性和共享生命周期所有权。它是库原语,而不是 Cordis 服务;生命周期设计理由由 [agent-scope 运行时设计 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer)规定,注册表层决策由[共享存储 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)规定,可调用 API 与过滤语义则由包 [README](../../packages/core/scope/README.md)规定。 源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) 与 [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts)。 @@ -54,6 +54,6 @@ interface ScopeLayer { } ``` -`ScopedLayers<L>` 拥有立即创建的全局 layer,以及惰性创建的确切作用域 layer。读取不会创建 layer:`peek(undefined)` 表示没有 overlay,而 `merge()` 会物化按插入顺序排列的全局具名 entry,随后是带作用域的 shadow。注册使用同一个 context 表示可见性与 Cordis effect 所有权,在可选通知前收集一个同步 undo,返回 Cordis 的确切 disposer,并且只在带作用域 layer 的完整 `ScopeLayer` 为空时回收它。 +`ScopedLayers<L>` 拥有立即创建的全局 layer,以及惰性创建的确切作用域 layer。读取不会创建 layer:`peek(undefined)` 表示没有 overlay,而 `merge()` 会物化按插入顺序排列的全局具名 entry,随后是带作用域的 shadow。注册使用同一个上下文表示可见性与 Cordis effect 所有权,在可选通知前收集一个同步 undo,返回 Cordis 的确切 disposer,并且只在带作用域 layer 的完整 `ScopeLayer` 为空时回收它。 `NamedEntries<V>` 提供按插入顺序的查找与 live iteration,重复错误由调用方所有。`AnonymousEntries<V>` 为每次 append 分配唯一标识,使相等的值仍相互独立。迭代在同一非空 table generation 内保持 live;排空 table 会让现有 iterator 与后续插入脱离。两者都返回幂等的确切 entry undo;共享的 `EntryValues` 实现接口不公开。 diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index f2f1de97ce..7efa74ce1b 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session-query.md: 27d0dccc6cf32b4dafebeaf64a3b8aefa138b98d -session-query.zh.md: 3b6d30916e7cc012859b86419fd326fadf02f571 +session-query.zh.md: 1c3abafb009cfc7376c96da4552b49959c09fef3 diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 3b6d30916e..1c3abafb00 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -1,8 +1,8 @@ -# Session Query +# 会话查询 [English](session-query.md) | 中文 -对优先使用 live 数据的逻辑 session 集合执行精确读取与关系追踪。[包契约](../../packages/session-query/session-query)拥有来源优先级、动态可选持久化、克隆、surface 分类、有界窗口、追踪校验与类型化失败。全文搜索属于另一个拟议的 SQLite 包。 +对优先使用 live 数据的逻辑会话集合执行精确读取与关系追踪。[包契约](../../packages/session-query/session-query)拥有来源优先级、动态可选持久化、克隆、surface 分类、有界窗口、追踪校验与类型化失败。全文搜索属于另一个拟议的 SQLite 包。 源码:[`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) @@ -57,7 +57,7 @@ interface SessionEventRecord { } ``` -## Session 谱系 +## 会话谱系 `SessionLineageTrace` 按由近及远的顺序携带已知 parent,并携带一片由直接 descendant 递归嵌套而成的森林。完整性判别字段使已知 root 与缺失 parent 互斥。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index d723c337ca..09662ed32a 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session.md: e07e38037e51c74886db88d24353266f6035b15a -session.zh.md: 2125f5ac97d3649aab166927b1607d54888fa9f1 +session.zh.md: 82ddaa2f53585f41fd9a343c9f710b339b2399fd diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 2125f5ac97..82ddaa2f53 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -108,7 +108,7 @@ interface SessionEventMap { } ``` -`PromptMessageData.content` 始终是确切的模型可见内容。当附加 context 声明 `prompt-prefix` 放置方式时,AgentLoop 会依次把它的 block、一个 `## My request:` 分隔符以及最终生效的直接提示词拼接进该数组。可选且对模型隐藏的 `envelope` 会保留 `displayContent`,以及按顺序排列的前缀 context 来源/元数据描述信息,使 transcript(文本记录)、标题与重新引用消费方无需改变可重建历史,就能呈现人类提示词。`displayPromptContent()` 负责该选择,并为普通事件和较早的事件回退到 `content`。 +`PromptMessageData.content` 始终是确切的模型可见内容。当附加上下文声明 `prompt-prefix` 放置方式时,AgentLoop 会依次把它的块、一个 `## My request:` 分隔符以及最终生效的直接提示词拼接进该数组。可选且对模型隐藏的 `envelope` 会保留 `displayContent`,以及按顺序排列的前缀上下文来源/元数据描述信息,使 transcript(文本记录)、标题与重新引用消费方无需改变可重建历史,就能呈现人类提示词。`displayPromptContent()` 负责该选择,并为普通事件和较早的事件回退到 `content`。 ### `OutOfBandSessionEventMap`:受限的带外追加显式准入 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index f7cf2d81de..2dcc46507e 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write subagent.md: 97d6862c10a0757c41472f207f857c25f3f5d50f -subagent.zh.md: 1e88562aed99122750e9d137cb0b722d4c38c36b +subagent.zh.md: de0e5c89d508565b84ad1240a11f069dc3795e9c diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 1e88562aed..de0e5c89d5 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -35,7 +35,7 @@ interface SubagentCapabilities { ## 启动请求 -工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、tool filter 和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture tool 实现所支持的 object-rooted schema。 +工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。 ```ts type-equiv /** @@ -200,7 +200,7 @@ interface SubagentRun { } ``` -本地 run 必须在 `start()` fulfill 前发布一个普通子 agent/session,将该子 session id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。 +本地 run 必须在 `start()` fulfill 前发布一个普通子 agent/会话,将该子会话 id 作为 `SubagentRun.id` 返回,以 `localAgent` 暴露确切子 agent,并在子 agent 的 `parentSession` header 中记录 `request.parent.session.id`。运行时所有权可以把子 agent 放在 parent、提供方或 root 作用域下。远程提供方则返回 parent 作用域的生命周期 id 与 `localAgent: undefined`。 ## 提供方 seam:`SubagentProvider` @@ -244,5 +244,5 @@ interface SubagentProvider { spawn 和 fork 后端通过 `parent.ctx` 创建一个普通 agent,将取消信号传入核心创建流程,并通过 `AgentHandle` 进行 dispose。移除提供方会阻止新的 start,但不会撤销已接受的 run。每个子 agent 获得一个新的扁平作用域,而非继承父级注册。深度与 fork 种子注入复用既有的 agent 和会话词汇: -- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,resume 无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 +- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 - **Fork 种子注入**使用 `CreateAgentOptions.seed`(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `resume` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/support/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml index 8e68fc099b..15f1076fea 100644 --- a/docs/core-data-structures/system-prompt.i18n.yaml +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write system-prompt.md: 63a750c74300b4353f132d3dae9da52a10631f23 -system-prompt.zh.md: 748e9e4406fe8ca1071aa87f5926bd043378ccdb +system-prompt.zh.md: e04510dbf8b97d01a568ffbd878de691be267272 diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md index 748e9e4406..e04510dbf8 100644 --- a/docs/core-data-structures/system-prompt.zh.md +++ b/docs/core-data-structures/system-prompt.zh.md @@ -2,7 +2,7 @@ [English](system-prompt.md) | 中文 -[system-prompt 包](../../packages/core/system-prompt)负责管理 prompt 贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 +[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 @@ -37,7 +37,7 @@ interface ToolProviderResult { } ``` -## Prompt 段落 +## 提示词段落 `PromptSection` 是一份只读的同进程注册契约。其文本可以是静态的,也可以从当前组装上下文动态解析。 diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 2f09ee1e2d..bc9fe6c5f3 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write tools.md: ce14a37da33f89b8b90d6d8e70756f94e3d690dd -tools.zh.md: 869480fc38688a15a8681648903e1a4a0959c79d +tools.zh.md: c85ebe4d77d60e83eadb91aa5dbddd6009913614 diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 869480fc38..c85ebe4d77 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -310,7 +310,7 @@ type PostToolDecision = 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 -后置策略可以替换内容;block 会变为包含纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 +后置策略可以替换内容;块会变为包含纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 ## 结构化输出 schema 子集 diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml index efcc7b3258..0eba5d9be2 100644 --- a/docs/core-data-structures/web.i18n.yaml +++ b/docs/core-data-structures/web.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write web.md: 20d07240c9d9fea2f1f5abbac810f349a3e81f9b -web.zh.md: d4d9259db5c834349c1cf9c72ed5c74b0a02b32b +web.zh.md: ab786f83457c16202f879be58ff55885523f874b diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md index d4d9259db5..ab786f8345 100644 --- a/docs/core-data-structures/web.zh.md +++ b/docs/core-data-structures/web.zh.md @@ -8,7 +8,7 @@ Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architect ## 为什么两项能力合为一个 seam -搜索与抓取既不共享请求 schema,也不共享业务逻辑,但它们被有意设计为同一个 `ctx.web` 中间层:一个提供方选择策略的所有者、一套 abort/error 词汇、一个面向产品的「此 harness 如何访问 Web」配置界面。代价是服务上并行的 `searchX`/`fetchX` 方法对;这种并行是有意为之,而非遗漏的提取。提供方注册的是**能力**(`WebSearchProvider` 或 `WebFetchProvider`),而非工具;面向模型的名称、schema、prompt 引导与展示全部集中在唯一的消费方 `dsh-tool-web` 中。 +搜索与抓取既不共享请求 schema,也不共享业务逻辑,但它们被有意设计为同一个 `ctx.web` 中间层:一个提供方选择策略的所有者、一套 abort/error 词汇、一个面向产品的「此 harness 如何访问 Web」配置界面。代价是服务上并行的 `searchX`/`fetchX` 方法对;这种并行是有意为之,而非遗漏的提取。提供方注册的是**能力**(`WebSearchProvider` 或 `WebFetchProvider`),而非工具;面向模型的名称、schema、提示词引导与展示全部集中在唯一的消费方 `dsh-tool-web` 中。 ## 搜索请求与结果 diff --git a/docs/core-data-structures/workflow.i18n.yaml b/docs/core-data-structures/workflow.i18n.yaml index cb0bf7e961..fecf373167 100644 --- a/docs/core-data-structures/workflow.i18n.yaml +++ b/docs/core-data-structures/workflow.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write workflow.md: 8d271b89e71de6f6bef548aa8da61402ef9ada6e -workflow.zh.md: 335f08cefe057bdc0d0f78a90e8301d5e457aae7 +workflow.zh.md: f603a50fc33fb9ab4260e8281ee04e892f677255 diff --git a/docs/core-data-structures/workflow.zh.md b/docs/core-data-structures/workflow.zh.md index 335f08cefe..f603a50fc3 100644 --- a/docs/core-data-structures/workflow.zh.md +++ b/docs/core-data-structures/workflow.zh.md @@ -4,13 +4,13 @@ 工作流 seam:一个 agent(智能体)运行由模型编写的编排脚本(SCRIPT),扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 -接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm context 位于其中);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 +接口:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。实现是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的消费方是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) ## 启动请求 -调用方启动 run 时提出的请求。普通 workflow 工具根据模型的 `{ script, meta, args }` 调用与发起调用的 agent 构建它;专用消费方还可以为该 run 选择一个引擎级 `subagentProvider` 并调低 `maxTotalAgents`,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据(引擎会对 `meta` 做形状校验,并在任何内容运行前大声拒绝——绝不会通过求值脚本文本来获取它)。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 +调用方启动 run 时提出的请求。普通工作流工具根据模型的 `{ script, meta, args }` 调用与发起调用的 agent 构建它;专用消费方还可以为该 run 选择一个引擎级 `subagentProvider` 并调低 `maxTotalAgents`,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据(引擎会对 `meta` 做形状校验,并在任何内容运行前大声拒绝——绝不会通过求值脚本文本来获取它)。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 ```ts type-equiv /** diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index bc2b7d097a..58f4bb356e 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0001-acp-default-export-drops-inject.md: ab3efc880cb5290dc149b6bacb276ccf581968c1 -0001-acp-default-export-drops-inject.zh.md: caf60dc086e892af4ae3563b0bf073c7e6373602 +0001-acp-default-export-drops-inject.zh.md: 38bbecb4444eef1a7cdc4f48235bb83d597ce1e5 diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index caf60dc086..38bbecb444 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -76,7 +76,7 @@ while (true) { 遍历**仅向祖先方向**进行。`sessionPersistence` 既不在 `AgentLoop` 的 fiber store 中(不在其 `static inject` 中),也不在通往 root 的任何祖先上(它位于一个*兄弟*分支),因此遍历到达根 fiber 后抛错。 -为什么内存中的 `AgentLoop` resume 测试没有捕获这个问题?因为它们从测试代码直接调用 `ctx.agents.resume(...)`——*在任何插件 fiber 之外*。此时 `ctx.fiber.runtime` 为 `null`,代理处理器走了一条提前绕过的路径: +为什么内存中的 `AgentLoop` 恢复测试没有捕获这个问题?因为它们从测试代码直接调用 `ctx.agents.resume(...)`——*在任何插件 fiber 之外*。此时 `ctx.fiber.runtime` 为 `null`,代理处理器走了一条提前绕过的路径: ```ts ignore-check if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct global-store lookup, no fiber walk @@ -91,7 +91,7 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob 两个 bug 共享同一个流程缺口:**没有任何测试通过插件的真实加载路径或真实调用拓扑来驱动它。** - 内存 harness 通过手动构建插件对象来挂载 bridge:`ctx.plugin({ name, inject, apply })`。这手动提供了 `inject`,因此永远无法复现 Bug #1——`unwrapExports` 只被 *Loader* 调用,`ctx.plugin` 从不调用它。即使 `ctx.plugin(NamespaceImport)` 也无法捕获。 -- 同一个 harness 将所有内容平铺挂载在一个根上下文上,因此从中触达的 `AgentLoop` resume 要么运行在顶层(`!runtime` 绕过),要么通过一个 origin 仍然解析在 root 上的 shadow——掩盖了 Bug #2 的祖先遍历失败。 +- 同一个 harness 将所有内容平铺挂载在一个根上下文上,因此从中触达的 `AgentLoop` 恢复要么运行在顶层(`!runtime` 绕过),要么通过一个 origin 仍然解析在 root 上的 shadow——掩盖了 Bug #2 的祖先遍历失败。 - 唯一的无 key e2e 发送 `initialize` 并检查 stdout 纯净性。`initialize` 从不触达 factory,因此两个 bug 都安然通过。 - 唯一驱动 `session/new`/`session/load` 的测试需要 key 才能运行,因此 CI(无 key)跳过了它——而本地它之所以「通过」,只是因为一个陈旧的已构建 `lib/`(包含旧代码)恰好满足了模块解析。 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index 21f7000078..19802cabab 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: d171ce7fe0fea830375f7494be2aac38630c8d6a +0002-js-expression-disabled-filesystem-tools.zh.md: 7b7c34bb77a141eaef383fd9a655caff2a484ce1 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index d171ce7fe0..7b7c34bb77 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -35,7 +35,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 已添加的防护措施 -- 文件系统场景启动 `fs.cordis.yml`:一个显式的固定全权限 overlay,配有对应的 replay 配置和独立的 request-header 类。 +- 文件系统场景启动 `fs.cordis.yml`:一个显式的固定全权限 overlay,配有对应的回放配置和独立的 request-header 类。 - [`AGENTS.md`](../../AGENTS.md) 与 [Cordis 入门](../cordis-primer.md#loader-configuration)明确说明 `!!js` 仅在插件 `config` 内有效,条件式组合应使用 overlay。 - `verify-cordis-config` 解析仓库中的 Cordis YAML,拒绝 Loader 入口元数据中的表达式节点(包括 include patch 和插入的入口)。 - `dsh-acp-snapshot` 在新鲜运行和已提交的会话 fixture 中拒绝结构化的 `UNKNOWN_TOOL` 结果,防止其被提交为预期输出。 From d5f5517ec01ea3bb30a3bfc4e095371da54150ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:11:59 +0800 Subject: [PATCH 020/113] docs(i18n): address Agent Note review feedback --- ...26-06-11-content-block-vocabulary.i18n.yaml | 2 +- .../2026-06-11-content-block-vocabulary.zh.md | 2 +- ...-06-17-filesystem-capability-seam.i18n.yaml | 2 +- ...2026-06-17-filesystem-capability-seam.zh.md | 6 +++--- .../2026-06-20-branded-ids.i18n.yaml | 2 +- .../architecture/2026-06-20-branded-ids.zh.md | 2 +- ...6-20-extract-example-app-packages.i18n.yaml | 2 +- ...26-06-20-extract-example-app-packages.zh.md | 6 +++--- .../2026-06-20-package-hierarchy.i18n.yaml | 2 +- .../2026-06-20-package-hierarchy.zh.md | 4 ++-- ...mandatory-app-attribution-headers.i18n.yaml | 2 +- ...-21-mandatory-app-attribution-headers.zh.md | 2 +- ...02-result-time-applied-hunk-diffs.i18n.yaml | 2 +- ...-07-02-result-time-applied-hunk-diffs.zh.md | 4 ++-- ...26-07-02-tool-render-intent-union.i18n.yaml | 2 +- .../2026-07-02-tool-render-intent-union.zh.md | 8 ++++---- ...ables-and-tool-guidance-ownership.i18n.yaml | 2 +- ...variables-and-tool-guidance-ownership.zh.md | 4 ++-- ...6-06-14-acp-agent-client-protocol.i18n.yaml | 2 +- .../2026-06-14-acp-agent-client-protocol.zh.md | 2 +- .../feature/2026-06-15-code-mode.i18n.yaml | 2 +- .../feature/2026-06-15-code-mode.zh.md | 2 +- ...026-06-17-filesystem-tool-schemas.i18n.yaml | 2 +- .../2026-06-17-filesystem-tool-schemas.zh.md | 18 +++++++++--------- ...8-acp-terminal-and-tool-rendering.i18n.yaml | 2 +- ...06-18-acp-terminal-and-tool-rendering.zh.md | 2 +- ...-06-18-compaction-capability-seam.i18n.yaml | 2 +- ...2026-06-18-compaction-capability-seam.zh.md | 2 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 4 ++-- .../2026-07-05-dynamic-workflows.i18n.yaml | 2 +- .../feature/2026-07-05-dynamic-workflows.zh.md | 6 +++--- .../feature/2026-07-06-sandbox.i18n.yaml | 2 +- .../feature/2026-07-06-sandbox.zh.md | 2 +- .../2026-07-07-mcp-client-plugin.i18n.yaml | 2 +- .../feature/2026-07-07-mcp-client-plugin.zh.md | 2 +- ...6-07-03-documentation-graph-atlas.i18n.yaml | 2 +- .../2026-07-03-documentation-graph-atlas.zh.md | 4 ++-- ...6-19-drop-mutable-session-summary.i18n.yaml | 2 +- ...26-06-19-drop-mutable-session-summary.zh.md | 2 +- ...im-acp-bridge-unreachable-surface.i18n.yaml | 2 +- ...4-trim-acp-bridge-unreachable-surface.zh.md | 2 +- ...2-fork-child-replay-seed-boundary.i18n.yaml | 2 +- ...06-22-fork-child-replay-seed-boundary.zh.md | 2 +- ...26-06-22-subagent-snapshot-replay.i18n.yaml | 2 +- .../2026-06-22-subagent-snapshot-replay.zh.md | 2 +- .../2026-06-16-typed-event-schemas.i18n.yaml | 2 +- .../2026-06-16-typed-event-schemas.zh.md | 2 +- ...6-07-08-interactive-side-sessions.i18n.yaml | 2 +- .../2026-07-08-interactive-side-sessions.zh.md | 2 +- ...6-06-11-immutable-public-surfaces.i18n.yaml | 2 +- .../2026-06-11-immutable-public-surfaces.zh.md | 2 +- ...6-06-20-providerless-example-base.i18n.yaml | 2 +- .../2026-06-20-providerless-example-base.zh.md | 2 +- ...-generate-agent-note-index-tables.i18n.yaml | 2 +- ...7-04-generate-agent-note-index-tables.zh.md | 2 +- ...assembled-assistant-messages-only.i18n.yaml | 2 +- ...-20-assembled-assistant-messages-only.zh.md | 2 +- .../2026-06-20-drop-acp-session-load.i18n.yaml | 2 +- .../2026-06-20-drop-acp-session-load.zh.md | 2 +- ...2026-06-20-drop-acp-terminal-meta.i18n.yaml | 2 +- .../2026-06-20-drop-acp-terminal-meta.zh.md | 2 +- ...6-20-drop-bash-output-spill-files.i18n.yaml | 2 +- ...26-06-20-drop-bash-output-spill-files.zh.md | 2 +- ...6-20-drop-durable-step-boundaries.i18n.yaml | 2 +- ...26-06-20-drop-durable-step-boundaries.zh.md | 2 +- ...06-20-drop-unused-session-lineage.i18n.yaml | 2 +- ...026-06-20-drop-unused-session-lineage.zh.md | 2 +- ...old-session-persistence-interface.i18n.yaml | 2 +- ...20-fold-session-persistence-interface.zh.md | 2 +- ...2026-06-20-generic-tool-rendering.i18n.yaml | 2 +- .../2026-06-20-generic-tool-rendering.zh.md | 2 +- ...26-06-20-retire-mid-turn-steering.i18n.yaml | 2 +- .../2026-06-20-retire-mid-turn-steering.zh.md | 2 +- ...6-06-20-single-session-acp-bridge.i18n.yaml | 2 +- .../2026-06-20-single-session-acp-bridge.zh.md | 2 +- ...-06-20-truncate-interrupted-turns.i18n.yaml | 2 +- ...2026-06-20-truncate-interrupted-turns.zh.md | 2 +- ...unimplemented-subagent-vocabulary.i18n.yaml | 2 +- ...une-unimplemented-subagent-vocabulary.zh.md | 4 ++-- ...lapse-workflow-to-foreground-core.i18n.yaml | 2 +- ...-collapse-workflow-to-foreground-core.zh.md | 2 +- ...une-unused-skill-registry-surface.i18n.yaml | 2 +- ...2-prune-unused-skill-registry-surface.zh.md | 2 +- 84 files changed, 107 insertions(+), 107 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index cc77840694..95bc498102 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-content-block-vocabulary.md: 9aad01cee6083b1f380be66869af3137a07d9f1f -2026-06-11-content-block-vocabulary.zh.md: 123ca87f55be5129855a330efbfe0818d7cdbc0c +2026-06-11-content-block-vocabulary.zh.md: 1427ef1a47ee3a54c1c93885befdf580b84f6399 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index 123ca87f55..1427ef1a47 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -23,6 +23,6 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 - 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 -- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 +- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 - 跨包(package)边界的 ID 使用品牌类型(`CallId`、agent 与 session 共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 8b36d4bdd1..e0fd8d2d7b 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-17-filesystem-capability-seam.md: cd8f8572730b1833ed1d7dbe1861d516c2d32925 -2026-06-17-filesystem-capability-seam.zh.md: a7a8d4cbc48c473b1f0669a95c5fbe25eecf94f4 +2026-06-17-filesystem-capability-seam.zh.md: fa06ecc4538258163ae17cd403d9e856c4d2911f diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index a7a8d4cbc4..fa06ecc453 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -32,11 +32,11 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 -第一个消费方有意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read`、`write` 和 `edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监视或更高层的项目操作,只要 `ctx.fs` 上存在所需能力,就无需改动本地后端包。直接目录列表后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。 +第一个消费方有意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read`、`write` 和 `edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监视或更高层的项目操作,只要 `ctx.fs` 上存在所需能力,就无需改动本地后端包。直接目录列表后来由[为文件系统 seam 添加直接目录列举能力](2026-07-03-filesystem-directory-listing-seam.md)添加。 文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 -读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](2026-06-26-file-context-as-event-gate.md) Agent Note。 +读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.md) Agent Note。 ## 包拓扑 @@ -95,7 +95,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 策略插件(而非 `ctx.fs`)对先前观测进行门控:`edit` 要求 owner 有先前观测(否则报 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传给 `editText`。在策略插件缺席时,`ctx.fs` 本身是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。 -文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码包括 `FS_NOT_FOUND`、`FS_NOT_TEXT`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_NOT_REGULAR_FILE`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND` 和 `FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已删除。目录列表相关的错误码后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。) +文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码包括 `FS_NOT_FOUND`、`FS_NOT_TEXT`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_NOT_REGULAR_FILE`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND` 和 `FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已删除。目录列表相关的错误码后来由[为文件系统 seam 添加直接目录列举能力](2026-07-03-filesystem-directory-listing-seam.md)添加。) ## 工具消费方行为 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index 29c4f4724d..d1a6a000db 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-branded-ids.md: 93bab1d47c793cc4dd1f1d19fa3af22d1721be29 -2026-06-20-branded-ids.zh.md: f45f39cc3a49f7abf116e77f1ace2db74fc3de67 +2026-06-20-branded-ids.zh.md: edf5cade38b579346f34a7ed2ee0a876af7a4c8b diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index f45f39cc3a..edf5cade38 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -8,7 +8,7 @@ Status: implemented harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和共享的 agent/session `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包(package) `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 -**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台任务 id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和 session id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 +**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 task id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和 session id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/bash/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml index 419c158b01..b2762a411f 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-extract-example-app-packages.md: b757a0099382a648a4efda4639e275ae6e6a02d1 -2026-06-20-extract-example-app-packages.zh.md: 5814704f0426707ef255a67a7aed9b79cae4aa24 +2026-06-20-extract-example-app-packages.zh.md: 85790354be8814f7563bbfe81b93067ae9c861ee diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md index 5814704f04..85790354be 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md @@ -51,7 +51,7 @@ Status: implemented ## 相关 -- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。 -- 基于 [capability-seams](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。 -- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放应用特有的前门)。 +- 取代[使共享示例基础配置与提供方无关](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。 +- 基于[能力 seam](2026-06-13-capability-seams.md)的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。 +- 与[将包重组为模块化层级结构](2026-06-20-package-hierarchy.md)互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放应用特有的前门)。 - 后续的[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)拥有最终的 TUI/Headless 拆分,并移除行式与仅 mock 的叶子。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml index bc70afb74c..3a860dbf97 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-package-hierarchy.md: aba5fb56176ecab9dd92c61a72cb91d501a442d8 -2026-06-20-package-hierarchy.zh.md: 304bf1da3631187d09b229fb14beb54d4e9a46cc +2026-06-20-package-hierarchy.zh.md: 5efd70bf0a82d646e5274e1a164805b9c35dfdbc diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md index 304bf1da36..5efd70bf0a 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-20-package-hierarchy.md) | 中文 -后续的 [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) 决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) 随后又彻底移除了该接口。这里拥有的决策仍是统一的二层目录深度。 +后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。这里拥有的决策仍是统一的二层目录深度。 ## 问题 @@ -57,7 +57,7 @@ packages/ - `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选)映射所有包,取代了逐包条目。根 `tsconfig.json` 复用该源映射,并携带显式 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选中包含 `/*/`,朴素的正则注释剥离器会将其误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手动剥离注释。) - `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导列表,解决了 `TODO(package-inventory)`。 -- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest(元数据清单)生成这些引用留作后续工作(见 [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md))。 +- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest(元数据清单)生成这些引用留作后续工作(见[通过发现机制获取包清单](../../proposed/process/2026-06-20-discover-package-inventory.md))。 ### 新增的护栏 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 32fb4fe9a9..e72b100327 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de -2026-06-21-mandatory-app-attribution-headers.zh.md: 724a828ce6a9907bec5615d1b2c3a53f2603e388 +2026-06-21-mandatory-app-attribution-headers.zh.md: 5529a42dddf4615ee1054b4d1dee36b077800d7d diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 724a828ce6..5529a42ddd 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -8,7 +8,7 @@ Status: implemented LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。 -直接触发因素来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 +直接触发因素来自 OpenRouter 的[应用归属](https://openrouter.ai/docs/app-attribution)文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 ## 调研 diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml index a2f2379e84..999b2cbd85 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-result-time-applied-hunk-diffs.md: b1e1884f2264f17f6fff17569d79d8352e3fc709 -2026-07-02-result-time-applied-hunk-diffs.zh.md: 091bbb6b9752238348c594a4abe39944b005755d +2026-07-02-result-time-applied-hunk-diffs.zh.md: f64d984df524f25f041a56b1f0bc287e1e4b77a6 diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md index 091bbb6b97..f64d984df5 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[tagged render-intent union](2026-07-02-tool-render-intent-union.md) 为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。 +[带标签的 render-intent 联合类型](2026-07-02-tool-render-intent-union.md)为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string`→`new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。 在对接 `claude-agent-acp` 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**(`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本「updated successfully」,没有 diff。 @@ -56,6 +56,6 @@ type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unkn ## 相关 -- 补全了 [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) 中作为非目标列出的最后一项表示差异——该 Agent Note 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 +- 补全了[带标签的 render-intent 联合类型](2026-07-02-tool-render-intent-union.md)中作为非目标列出的最后一项表示差异——该 Agent Note 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。 - 基于[文件系统 capability seam](2026-06-17-filesystem-capability-seam.md)(before/after 是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)(`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)。 - `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 6f93853732..10be0c15fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-tool-render-intent-union.md: c7adf8f405000ec7940f82e1e1e2406d86253461 -2026-07-02-tool-render-intent-union.zh.md: c77455b2d8c47add16e36f1b8a8ac3bb12f02707 +2026-07-02-tool-render-intent-union.zh.md: 9e9248b95acbbf9d197322acd2f38f594aef3bf1 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index c77455b2d8..9e9248b95a 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -12,7 +12,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot 回放路径)。 +`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot 回放路径)。 ## 决策 @@ -76,7 +76,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## 相关 -- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。 -- 被 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 -- 将 `ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view(`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。 +- 取代[折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。 +- 被[结果时已应用 hunk 差异](2026-07-02-result-time-applied-hunk-diffs.md)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 +- 将 `ToolTerminal` 折入 [ACP terminal 与工具调用渲染](../feature/2026-06-18-acp-terminal-and-tool-rendering.md)所描述的 `terminal` view(`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。 - ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 87ead153fa..109fbae933 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 2342b89ab666a7987f78cfeabe6fa90ce9dd0cad +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 572a95c34381f6d73d1f6e053e565f30704026d3 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 2342b89ab6..572a95c343 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -40,7 +40,7 @@ Status: implemented ### Subagent 对话历史描述符 -`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 +`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 ## 曾考虑的替代方案 @@ -49,7 +49,7 @@ Status: implemented - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 Agent Note 要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**:`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。 -- **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 +- **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 ## 不在范围内 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml index 5d47778692..56f3e87525 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml @@ -3,4 +3,4 @@ # 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: 711e8c2a7f7146494fa208942523e2ebfa1808a5 +2026-06-14-acp-agent-client-protocol.zh.md: 2bc6f71a8d370d65ccc3624724528a99f452bd9f diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md index 711e8c2a7f..2bc6f71a8d 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md @@ -24,7 +24,7 @@ harness 最初仅通过 readline 循环暴露 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 卡片和文件位置,桥接层中无需硬编码工具名分支。 -权限处理是 [user-approval seam](2026-07-06-approval-seam.md) 上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 保持 fail-closed。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。 +权限处理是[用户审批 seam](2026-07-06-approval-seam.md)上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 保持 fail-closed。发起询问的插件(如预执行策略或 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` 保持连接级别。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 97e09d562e..3ac7ec65d0 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-15-code-mode.md: f403e87601aad0fda5d53ec5b94ba44452e79b49 -2026-06-15-code-mode.zh.md: b4b432a6ce2206ea4920f89275d431ed2d3d0c44 +2026-06-15-code-mode.zh.md: c119bcfc5edb3d94f80390a9c16bcb6246500ed0 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index b4b432a6ce..c119bcfc5e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -48,7 +48,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。 -**呈现。** `run_code` 的 render intent 按 [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md) 在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 +**呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 ### 可观测性:`tool/code-dispatch` diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index 35f14a2678..c3ef031858 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml @@ -3,4 +3,4 @@ # 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: fa23dccc7ae98a9c25b9c474a75dc1e2f8e5ff21 -2026-06-17-filesystem-tool-schemas.zh.md: 30acf26a9bb2858a9f786591b6e01626593713d0 +2026-06-17-filesystem-tool-schemas.zh.md: 79f172083fd2827eaa84489550577e10fd98c114 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index 30acf26a9b..79f172083f 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[文件系统能力 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 策略——[split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) Agent Note 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read`、`write` 和 `edit` 中看到哪些参数。 +[文件系统能力 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 为原型选择最小的共有接口。 @@ -14,11 +14,11 @@ Status: implemented `@deepseek-ai/dsh-tool-fs` 在首个文件系统工具套件中暴露以下三个面向模型的工具: -| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype | +| 工具 | 我们的 schema | Claude Code | OpenCode | 说明 | 原型包含 | |---|---|---|---|---|---| -| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES | -| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES | -| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES | +| `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/*` 事件分发。 @@ -74,11 +74,11 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面 默认原生投影: -| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection | +| 工具 | `tool-fs` 使用的结构化 `ctx.fs` 结果 | 默认模型投影 | |---|---|---| -| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer | -| `write` | create/update operation, target display path, new file version | concise create/update success text | -| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text | +| `read` | 返回的行、返回行数、总行数、目标显示路径、文件版本、部分视图标记 | 带行号的文本及分页页脚 | +| `write` | 创建/更新操作、目标显示路径、新文件版本 | 简洁的创建/更新成功文本 | +| `edit` | 替换次数、全量替换标记、目标显示路径、新文件版本 | 简洁的编辑成功文本 | 结构化结果不会重复模型参数(如 `file_path`、`old_string` 或 `content`),除非后端已将其解析为新信息(如 `displayPath`、`targetKey` 或新版本)。面向 token 的截断属于模型投影的职责,而非后端规范结果的一部分。 diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml index 5ca5a1e59d..0852385897 100644 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml @@ -3,4 +3,4 @@ # 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: 924ea9fb50a77314748d4826b5c408d5243b32f9 +2026-06-18-acp-terminal-and-tool-rendering.zh.md: 1623c8f51f9a1e0aacf374d182ab1c74dfa407cc diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md index 924ea9fb50..1623c8f51f 100644 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见 [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) 与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 +ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 呈现](2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 56723c5c4c..afe78658ef 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-compaction-capability-seam.md: a263b5e7d0245bd1279024a50e05b2f33edad521 -2026-06-18-compaction-capability-seam.zh.md: 79c883c364c9045fa2f85e7743091f83f0553783 +2026-06-18-compaction-capability-seam.zh.md: b86393332592bd92f6e6be81d610370c8c2bb23e diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 79c883c364..b863933325 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -8,7 +8,7 @@ Status: implemented 长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即截断响应(`max-tokens`)或性能退化。**上下文压缩(context compaction)** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。 -[session surface](../architecture/2026-06-18-session-surface.md) 正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段条目并插入替换内容,`sourceEventSeqs` 记录溯源信息以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。 +[会话接口面](../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`。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 02ea526412..2aed6c1f15 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -3,4 +3,4 @@ # 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: 9b4ebfc0afaeed6e599f587d32ad2d2af43b6b68 +2026-06-30-hook-bridges.zh.md: 70d55c62f3d8e43af9deb0f847b1dec7dc474617 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 9b4ebfc0af..70d55c62f3 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -12,7 +12,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( ## 决策 -`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见 [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`: +`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`。 @@ -23,7 +23,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( | Seam | CC | Codex | |---|---|---| -| `agent/session-start`(emit) | additionalContext → `agent.inject()` | plain-stdout output → additionalContext → `agent.inject()` | +| `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 | 同上 | diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index 17679363d0..8f4da1a9fc 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-dynamic-workflows.md: 353ab56aaac2d0d7624ff35f03cc7073e50a1a7d -2026-07-05-dynamic-workflows.zh.md: aeb1f736c9674b37fcd249529e0f7dc4dac89cbb +2026-07-05-dynamic-workflows.zh.md: 7919fe12d1f1342738dafd8378c8c7f1dc932c0d diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index aeb1f736c9..7919fe12d1 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立部分的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划无处持久存储,每一步的协调都要消耗一次模型往返。Claude Code 以 [dynamic workflows](https://code.claude.com/docs/en/workflows) 的形式提供了这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。 +harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`),但需要扇出到多个独立部分的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排:每个中间结果都落入父上下文,计划无处持久存储,每一步的协调都要消耗一次模型往返。Claude Code 以[动态工作流](https://code.claude.com/docs/en/workflows)的形式提供了这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。 ## 决策 @@ -28,7 +28,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) **为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。 -宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式;待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 静默,在此协议上保持 subagent run 契约。这些竞态算法归 [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) 所有。 +宿主在发布前校验元数据并解析正文。私有枚举键 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 执行。 @@ -46,7 +46,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) 输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误;没有已提交捕获的正常完成以错误结算。 -`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归 [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) 所有。 +`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归[agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes)所有。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 9257bc6041..905cfc1382 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-sandbox.md: 738a1796b047561b861fc237154210659515bd6f -2026-07-06-sandbox.zh.md: 6843ef96413c00a12a2d1c6b70d2c5b90c6e02f7 +2026-07-06-sandbox.zh.md: ad7d7e83a1a17fffffd33ac09a7cc775c0eb4895 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6843ef9641..ad7d7e83a1 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -107,7 +107,7 @@ interface SessionEventMap { 沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'never'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个 pre-step 递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的。 -**编辑器界面**是协议原生的 [Session Config Options](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 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom`。`session/set_config_option` 通过 permission 服务验证并切换,然后返回完整的刷新状态(规范契约)。 +**编辑器界面**是协议原生的[会话配置选项](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 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom`。`session/set_config_option` 通过 permission 服务验证并切换,然后返回完整的刷新状态(规范契约)。 **轮次封闭是提交边界。** 开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定,在下一次 prompt 提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`,setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 6d5a63b85f..2db4756817 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -3,4 +3,4 @@ # 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: 49cce88547eb731ae5b5bf168a7a0a4a785efe94 +2026-07-07-mcp-client-plugin.zh.md: 470d43ec8b4b1553304949278c26a6d1c9b9cc58 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 49cce88547..470d43ec8b 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -143,7 +143,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。 2. 映射结果: - 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 无分隔符,多块会丢失块间边界)。 - - `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md))。 + - `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`。 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml index bd5dda5aa4..fc808c5163 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-03-documentation-graph-atlas.md: 9a30b13f9db6ceb2715517230e349cbe083850ec -2026-07-03-documentation-graph-atlas.zh.md: 438f1e6de696712d8dd2f1b503d15d1b42506cf9 +2026-07-03-documentation-graph-atlas.zh.md: d4259ea8d977d4966d6c909414bd0dc586a2d145 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md index 438f1e6de6..d4259ea8d9 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -38,11 +38,11 @@ Status: implemented | [tui-agent 应用组合](../../../../examples/tui-agent/composition.md) | 混合生成式 | `examples/tui-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [headless-agent 应用组合](../../../../examples/headless-agent/composition.md) | 混合生成式 | `examples/headless-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [cordis-agent 应用组合](../../../../examples/cordis-agent/composition.md) | 混合生成式 | `examples/cordis-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | -| [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | +| [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | 混合生成式 | `examples/acp-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | | [事件生产者/消费者矩阵](../../../../docs/event-producer-consumer.md) | 混合生成式 | Cordis 事件声明、经 AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 位置,以及显式动态分派覆盖 | | [agent turn 与 step 生命周期](../../../../docs/agent-lifecycle.md) | 人工维护 | architecture.md 循环生命周期、Cordis 目录链接,以及 session 事件语义 | | [工具执行管线](../../../../docs/tool-execution-pipeline.md) | 人工维护 | 工具管线语义与 `tools/execute` waterfall(瀑布式事件)| -| [ACP(Agent Client Protocol)快照回放](../../../../packages/ui/acp/snapshot-replay.md) | curated | 快照 harness 行为 | +| [ACP(Agent Client Protocol)快照回放](../../../../packages/ui/acp/snapshot-replay.md) | 人工策划 | 快照 harness 行为 | ### 为什么由生成器拥有文档 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index aeda386ff4..caebea33ff 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-drop-mutable-session-summary.md: 80fe043e365352b17d2a5b3efa1ab8d396d311c4 -2026-06-19-drop-mutable-session-summary.zh.md: ec98c19093369400b9b42a5c1a0590b5c6913ab9 +2026-06-19-drop-mutable-session-summary.zh.md: e2326c21681eaa7d0a325264c24f188379ec4b21 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index ec98c19093..e2326c2168 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[session-persistence seam](../architecture/2026-06-14-session-persistence.md) 将会话的日志外元数据拆分为 `dsh-session` 拥有的两种类型:一个不可变的 `SessionHeader`(`version`、`id`、`createdAt`、`cwd?`、`parentSession?`),在创建时一次性写入;一个可变的 `SessionSummary`(`updatedAt`、`title?`、`firstPrompt?`),「可在不触碰仅追加日志的情况下更新」。二者的联合类型为 `SessionMeta = SessionHeader & SessionSummary`,抽象的 `SessionPersistence` 服务为此多出第七个方法 `update(id, summary)`,用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁写一个独立的原子 `.summary.json` **伴随文件**(临时写入 + rename,尽力保证);SQLite 在追加事务内更新 `updated_at`/`title`/`first_prompt` **列**。 +[会话持久化 seam](../architecture/2026-06-14-session-persistence.md)将会话的日志外元数据拆分为 `dsh-session` 拥有的两种类型:一个不可变的 `SessionHeader`(`version`、`id`、`createdAt`、`cwd?`、`parentSession?`),在创建时一次性写入;一个可变的 `SessionSummary`(`updatedAt`、`title?`、`firstPrompt?`),「可在不触碰仅追加日志的情况下更新」。二者的联合类型为 `SessionMeta = SessionHeader & SessionSummary`,抽象的 `SessionPersistence` 服务为此多出第七个方法 `update(id, summary)`,用于重写摘要。各后端各自实现可变存储:JSONL 在日志旁写一个独立的原子 `.summary.json` **伴随文件**(临时写入 + rename,尽力保证);SQLite 在追加事务内更新 `updated_at`/`title`/`first_prompt` **列**。 摘要是为未来的会话选择器设计的(通过 `updatedAt` 排序近期会话,用 `title`/`firstPrompt` 做预览)。该选择器从未实现。对整个仓库的审计表明,`SessionSummary` 的全部表面积都是**死状态**: diff --git a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml index 9de0d532bc..12bf20b67f 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-trim-acp-bridge-unreachable-surface.md: ce0c623930192d02c8347c3956c497ee13048904 -2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: ea94f28b4802c18394cc3f6e6d4bdfa8702dae2b +2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 31027db1f3b5f79a9566a2fa3ccda787b52960e7 diff --git a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md index ea94f28b48..31027db1f3 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md @@ -9,7 +9,7 @@ Status: implemented `dsh-acp` 有两处对外表面在任何已交付的配置中都不可达: 1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已发布应用包只向 bridge 传递 `{ model }`(`packages/examples/acp-demo/src/index.ts`),因此没有任何叶子 `cordis.yml`——唯一的生产配置表面——能够设置这些配置项;只有直接挂载 bridge 才能设置它们,而这种做法只存在于一个单元测试中。每份快照预期输出——包括 hook 矩阵场景——都固定 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对配置项还带有一个尚未解决的 `TODO(double-default)`:字面量存在两次(schema `.default(...)` 加 `??` 后备值),TODO 要求为它们选择一个归属。 -2. **`toolKindFor` 名称启发式**(同一文件)在通用回退路径中对 `bash*`/`read*`/`write`/`edit*` 工具名做了特殊处理。自 [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) 以来,这些分支匹配到的每个第一方工具都自带 `presentCall` 并携带其 kind,而没有 presenter 的生产工具(`subagent`、`subagent_fork`)本来就落入 `other`。这些分支只有在工具拒绝自行呈现调用时才在生产中可达:`presentCall` 抛出异常(容错回退),或模型参数未通过工具 schema 导致 `defineTool` 的 `presentCall` 包装层返回 `undefined`(例如 `bash` 调用缺少必需的 `description`)。而桥接层自身的模块文档明确声明了该启发式所违反的设计规则:"桥接层绝不对工具名做特殊处理"。 +2. **`toolKindFor` 名称启发式**(同一文件)在通用回退路径中对 `bash*`/`read*`/`write`/`edit*` 工具名做了特殊处理。自[render-intent 联合类型](../architecture/2026-07-02-tool-render-intent-union.md)以来,这些分支匹配到的每个第一方工具都自带 `presentCall` 并携带其 kind,而没有 presenter 的生产工具(`subagent`、`subagent_fork`)本来就落入 `other`。这些分支只有在工具拒绝自行呈现调用时才在生产中可达:`presentCall` 抛出异常(容错回退),或模型参数未通过工具 schema 导致 `defineTool` 的 `presentCall` 包装层返回 `undefined`(例如 `bash` 调用缺少必需的 `description`)。而桥接层自身的模块文档明确声明了该启发式所违反的设计规则:"桥接层绝不对工具名做特殊处理"。 ## 决策 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index 3ad2200f26..00899acd09 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-fork-child-replay-seed-boundary.md: d3cbbb1dae1d64a10973bd5895ccc47d877eba28 -2026-06-22-fork-child-replay-seed-boundary.zh.md: 7137256f0e1f34a2da928b966d170d054f18ddde +2026-06-22-fork-child-replay-seed-boundary.zh.md: 8af1ba869ba5484442a6575f78556f47abe99d22 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index 7137256f0e..8af1ba869b 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -35,7 +35,7 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla `dsh-llm-replay` 的 `parseSessionHeader` 现在也读取 `seedLength`(缺失则为 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(seedLength)` 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 `seedLength` 为 0,此操作是空操作,spawn 场景逐字节不变。 -这关闭了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)。 +这关闭了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见[记录 fork 与混合 spawn+fork 快照场景](2026-06-22-fork-snapshot-scenarios.md)。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index 479d521c97..c9f3fa8acc 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-subagent-snapshot-replay.md: 4aad8b6ddd3e4f8b8ad5565e10b263953d81ea31 -2026-06-22-subagent-snapshot-replay.zh.md: 9d31e4d7f8e65b8442a94dee154ff1d3a5631651 +2026-06-22-subagent-snapshot-replay.zh.md: 77a4302fe3aa529972195f87306b24493bbcdd06 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index 9d31e4d7f8..77a4302fe3 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -54,5 +54,5 @@ Status: implemented - `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。 - `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外同样有用(遥测、请求路由)。 -- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。 +- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见[持久化种子边界,使 fork 子项重放能够正确路由](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见[记录 fork 与混合 spawn+fork 快照场景](2026-06-22-fork-snapshot-scenarios.md))。 - 进程外(ACP(Agent Client Protocol))subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index dd6683aaa3..a6cdff0d66 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-16-typed-event-schemas.md: 97a7d0c3787eb5556696e25a6c8b1bb75642aba9 -2026-06-16-typed-event-schemas.zh.md: f79a607eaa723c00b00e984e1a6983d24d99c127 +2026-06-16-typed-event-schemas.zh.md: a02ff8ed54a1d8ea02690c4b6ceaeef4338d3123 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index f79a607eaa..a02ff8ed54 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -32,7 +32,7 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 - **事件生产者**——agent loop(智能体循环)中 16 处 `session.append(...)` 调用——形状不变,但现在在边界处被校验。 - **约 7 个 switch 消费方**,对这些联合类型进行分支:`deriveMessages` 与包自有的不变式 companion(`dsh-session`)、`BlockAssembler`(`dsh-llm`)、两个 LLM(大语言模型)适配器(`dsh-llm-deepseek`、`dsh-llm-pi-ai`)以及工具 schema 层(`dsh-tools`)。`assertNever` 对封闭联合类型的穷举 vs 对可扩展联合类型的 fall-through 约定(一条已记录的 lint 规则)需要重新考量——运行时变体在静态层面不可穷举。 - **`defineTool` 的 `InferArgs` DSL**(`dsh-tools`),它从编译期 schema 规范派生出零类型转换的 `execute` 参数类型——这是当前方案的标杆用例。 -- **文档**:architecture.md(该模式被描述为基础性的)、[dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md),以及所有引用该模式的 Agent Note。 +- **文档**:architecture.md(该模式被描述为基础性的)、[开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md),以及所有引用该模式的 Agent Note。 这是一次仓库级别的词汇重新设计,而非持久化的实现细节。 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml index 08f5e8380e..a7ee5ef34a 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-interactive-side-sessions.md: dfd325babe215782c9c1cbec3fd9f874783af7ab -2026-07-08-interactive-side-sessions.zh.md: d545f65a821c6057b062300f31c415a2d7f63860 +2026-07-08-interactive-side-sessions.zh.md: 6a1d11c0566a36fcbf882daad0b26590460e1534 diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md index d545f65a82..6a1d11c056 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -用户可能希望在不改变当前会话主上下文的前提下,探索一个来自活跃会话的问题。现有原语无法提供这种产品形态:[session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) 创建的是一个无关联的会话,而 [fork subagent](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 是模型驱动的任务,其 transcript(文本记录)会折叠为一条工具结果。两者都不能给用户一个独立的对话,也都不能将结论带着出处信息记录回父会话。 +用户可能希望在不改变当前会话主上下文的前提下,探索一个来自活跃会话的问题。现有原语无法提供这种产品形态:[会话存储 fork](../../implemented/feature/2026-06-30-session-store-fork-api.md)创建的是一个无关联的会话,而 [fork subagent](../../implemented/feature/2026-06-21-subagent-capability-seam.md)是模型驱动的任务,其 transcript(文本记录)会折叠为一条工具结果。两者都不能给用户一个独立的对话,也都不能将结论带着出处信息记录回父会话。 ## 提案 diff --git a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml index 89414acd96..03c09bb8df 100644 --- a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml +++ b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-immutable-public-surfaces.md: c9009ad923720efaecb25e2017ceab6e3eb0dbf4 -2026-06-11-immutable-public-surfaces.zh.md: 2516450c61eab399b2cb64862af164289eb87fbe +2026-06-11-immutable-public-surfaces.zh.md: 7ed778505547fc91077a7e201a62123c8418e5f1 diff --git a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md index 2516450c61..7ed7785055 100644 --- a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md +++ b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md @@ -1,6 +1,6 @@ # Agent Note: 深度只读的公开接口 -Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). +Status: rejected — 普遍采用 `DeepReadonly<T>` 的类型翻转已由 `Session` 中归属源的运行时不可变性与关系型开发断言取代。见[归属源的会话不可变性与开发模式不变式](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md)。 [English](2026-06-11-immutable-public-surfaces.md) | 中文 diff --git a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml index c19ca144a0..512afb19c3 100644 --- a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml +++ b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-providerless-example-base.md: 2f41476a487775e6f9da2f113efe566e44786ff3 -2026-06-20-providerless-example-base.zh.md: 61d9f819b530fa9a15e8ed5b9d3f13f945e10b82 +2026-06-20-providerless-example-base.zh.md: ebe97db5a6cf194131239271e2713a1b0d14fc2a diff --git a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md index 61d9f819b5..ebe97db5a6 100644 --- a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md +++ b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md @@ -1,6 +1,6 @@ # Agent Note: 使共享示例基础配置与提供方无关 -Status: rejected — superseded by [Extract example apps into packages](../../implemented/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-spine-demo` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. +Status: rejected — 已由[将示例应用提取到 packages 中](../../implemented/architecture/2026-06-20-extract-example-app-packages.md)取代;后者把 spine 移入 `dsh-agent-spine-demo` bundle 并删除 `base*.yml` 文件,因此已不存在可重命名的共享基础 YAML。 [English](2026-06-20-providerless-example-base.md) | 中文 diff --git a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml index 306dac3774..0ea6f9f843 100644 --- a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml +++ b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-generate-agent-note-index-tables.md: 6e5221f018942a0629f30b6e6f22cedfb9f4145e -2026-07-04-generate-agent-note-index-tables.zh.md: f299fc5c2df25064a2712fa177fdbe5b73ffc7ca +2026-07-04-generate-agent-note-index-tables.zh.md: 572b6c743b661075f37e8602a81e0ceb800921a5 diff --git a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md index f299fc5c2d..572b6c743b 100644 --- a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md +++ b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md @@ -1,6 +1,6 @@ # Agent Note: 生成 Agent Note 索引表 -Status: rejected — a centralized generated list is merge-prone and adds little discovery value +Status: rejected — 集中生成的列表容易产生合并冲突,且几乎不增加发现价值 [English](2026-07-04-generate-agent-note-index-tables.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml index adeb1b14f9..034b4df2e0 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-assembled-assistant-messages-only.md: ba8135a3d63f292cfedd23de8b4b9d43b4455e8c -2026-06-20-assembled-assistant-messages-only.zh.md: 963d807afef29a3f7d8f0f57fc89ef7f6f3187c4 +2026-06-20-assembled-assistant-messages-only.zh.md: ef24a2eafbdeb71c7637f52804892abaa8e80fa6 diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md index 963d807afe..ef24a2eafb 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md @@ -1,6 +1,6 @@ # Agent Note: 仅持久化组装后的 assistant 消息,不存储流式分片 -Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement. +Status: rejected — 高保真 chunk 重放、部分失败流与快照重放目前依赖持久化的 `assistant/chunk` 事件。只有具备不丢失信息的重放/artifact 替代方案后,才能删除 chunk。 [English](2026-06-20-assembled-assistant-messages-only.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml index 052b380049..174d0802cd 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-acp-session-load.md: 2b6edf173506929f2d64e910b47aa70fb4f1d854 -2026-06-20-drop-acp-session-load.zh.md: 8e7a4ed6d7d1ccc89da3340907c11d1cf35505bd +2026-06-20-drop-acp-session-load.zh.md: c63ac09903ecfa1aaad44d41e70e452bdcb17bf3 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md index 8e7a4ed6d7..c63ac09903 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除 ACP session/load,直到恢复具备产品形态 -Status: rejected — Zed is the current target ACP client, advertises and exercises load-capable sessions, and keeps pending-load state for concurrent `session/load`. The bridge should keep `session/load` and make the resume contract solid. +Status: rejected — Zed 是当前目标 ACP 客户端,它声明并实际使用支持加载的会话,还为并发的 `session/load` 保留待加载状态。桥接层应保留 `session/load` 并巩固恢复契约。 [English](2026-06-20-drop-acp-session-load.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index 6ead85178e..f3b457a8b2 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-acp-terminal-meta.md: 79da387ac1a7a0e6767e3bf24baa6039e39ef90d -2026-06-20-drop-acp-terminal-meta.zh.md: 5aa455959c403d7e03b36be0d4336fdc5389c52e +2026-06-20-drop-acp-terminal-meta.zh.md: e34cd0690beed5d3a1715db6bc3e8413e6227440 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index 5aa455959c..e34cd0690b 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除 ACP 终端 `_meta` 渲染 -Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. +Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是有意设计的 Zed UX,同时为其他客户端保留普通 ACP 回退。 [English](2026-06-20-drop-acp-terminal-meta.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml index 676d14dd4e..514662e600 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-bash-output-spill-files.md: b2bd1a04ee1524bab29814ffa7c22712a83ee5f7 -2026-06-20-drop-bash-output-spill-files.zh.md: c8c0df30eda2ab6800d1e8f814d05f8f15103fd2 +2026-06-20-drop-bash-output-spill-files.zh.md: 4a2a8e7d3b0477c0a221e9fe6abe879cfa513aeb diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md index c8c0df30ed..4a2a8e7d3b 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除 bash 完整输出溢出文件 -Status: rejected — full-output recovery is a real bash behavior. A future artifact/blob service may generalize it, but dropping spill files before that replacement would lose useful command output. +Status: rejected — 完整输出恢复是真实的 bash 行为。未来的 artifact/blob 服务或许能将其泛化,但在替代方案就位前删除溢出文件会丢失有用的命令输出。 [English](2026-06-20-drop-bash-output-spill-files.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml index d91be8381f..b5462fbea8 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-durable-step-boundaries.md: c5c4f269a378e334c4dc509d1288146d77d9a520 -2026-06-20-drop-durable-step-boundaries.zh.md: 7724a8f6c2c51650eb9ea0a6675ece7c24a211d8 +2026-06-20-drop-durable-step-boundaries.zh.md: e98f5e479cdef43bf134e3e2d240ce0f3be7ce0e diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md index 7724a8f6c2..e98f5e479c 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除持久化的步骤边界事件 -Status: rejected — `step/end` is the durable indication that a model step finished, and keeping the symmetric `step/start` / `step/end` pair makes crash repair, invariants, and transcript inspection clearer than inferring completion from adjacent step-scoped events. +Status: rejected — `step/end` 是 model step 已完成的持久信号;保留对称的 `step/start` / `step/end` 对,比从相邻的 step-scoped 事件推断完成状态更便于理解崩溃修复、不变式与 transcript 检查。 [English](2026-06-20-drop-durable-step-boundaries.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml index c65ced3c8b..a5b3ffc665 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unused-session-lineage.md: 605f1949999435b24404e0c5a72320416303ae52 -2026-06-20-drop-unused-session-lineage.zh.md: 5d37a287fe1cb2d3cf196a6779dc11702826fa4a +2026-06-20-drop-unused-session-lineage.zh.md: f49e9af0fe22be41228b058f5c700815cc7aa893 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md index 5d37a287fe..f49e9af0fe 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除未使用的会话血缘元数据 -Status: rejected — `parentSession` is part of the documented fork/sub-agent seam and is already preserved by the agent/session resume path. The field is future-facing, but it is not accidental dead state. +Status: rejected — `parentSession` 是已记录的 fork/subagent seam 的一部分,并已由 agent/session 恢复路径保留。该字段面向未来,但并非意外遗留的死状态。 [English](2026-06-20-drop-unused-session-lineage.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml index 1cea1f5177..76c31d49a5 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-fold-session-persistence-interface.md: 895b868b2a80d8655284bae1364a85e19e174da7 -2026-06-20-fold-session-persistence-interface.zh.md: 12c710d2235ff25c2c961cfdac31c0c01c3afdda +2026-06-20-fold-session-persistence-interface.zh.md: 8b1d87431b27d91c9b9765055d783dea940b981d diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md index 12c710d223..8b1d87431b 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -1,6 +1,6 @@ # Agent Note: 将持久化接口合并进 dsh-session -Status: rejected — the separate persistence interface package is the intended modular capability seam for durable backends. Folding it into `dsh-session` would reduce package count at the cost of a cleaner backend boundary. +Status: rejected — 独立的持久化接口包是为持久后端设计的模块化能力 seam。将其折叠进 `dsh-session` 虽能减少包数量,却会牺牲更清晰的后端边界。 [English](2026-06-20-fold-session-persistence-interface.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml index 8001c0937f..a95113afcc 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generic-tool-rendering.md: 6fc610546da04e7d1e16fc17ada87483a142aa3c -2026-06-20-generic-tool-rendering.zh.md: e19907b71dcb93d325f94695c05bda23fca254cc +2026-06-20-generic-tool-rendering.zh.md: 4310e35a71fb13dfe1b00190da444a56bc8b4e81 diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md index e19907b71d..4310e35a71 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -1,6 +1,6 @@ # Agent Note: 收拢工具自有的 UI 展示逻辑 -Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. +Status: rejected — 工具拥有的呈现机制应等到出现更多真实工具后再进行泛化或删除。Bash 与 ACP 目前仍需要现有的丰富呈现路径。 [English](2026-06-20-generic-tool-rendering.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml index 73df65d34f..e591923f6e 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-retire-mid-turn-steering.md: a8812b3222739244d77f4d4dab60cf7c0cd6907d -2026-06-20-retire-mid-turn-steering.zh.md: 6beb00a646fd16b7bd9d987fb28429771145d6fc +2026-06-20-retire-mid-turn-steering.zh.md: c4de65bc763344ed4a060244515a403b695d7fd9 diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md index 6beb00a646..c4de65bc76 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除轮次中途引导 -Status: rejected — mid-turn steering is an intentional agent capability for between-step user/plugin input and future goal/loop workflows. It is complexity with a product direction, not an accidental duplicate of `send()`. +Status: rejected — mid-turn steering 是一项有意设计的 agent 能力,用于接收 between-step 的用户/插件输入以及未来的 goal/loop 工作流。它是面向产品方向的复杂度,而非 `send()` 的意外重复。 [English](2026-06-20-retire-mid-turn-steering.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml index c4b40325a3..550a17a6b1 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-single-session-acp-bridge.md: e99de76854390a0979d1b66866d1d48aacbc0036 -2026-06-20-single-session-acp-bridge.zh.md: 1db5484c014178dfedceab20221a3d317877c35f +2026-06-20-single-session-acp-bridge.zh.md: e4c9a4c9371749f02a4a7c4f55d724ba4641e2c0 diff --git a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md index 1db5484c01..e4c9a4c937 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md @@ -1,6 +1,6 @@ # Agent Note: 将 ACP 桥接恢复为每连接一个活跃会话 -Status: rejected — Zed is the current target ACP client and its ACP implementation is explicitly multi-session: it stores live sessions in a `HashMap<SessionId, AcpSession>`, tracks `pending_sessions`, joins concurrent loads for the same id, and tests close-during-load behavior. +Status: rejected — Zed 是当前目标 ACP 客户端,其 ACP 实现明确支持多会话:它把活跃会话存入 `HashMap<SessionId, AcpSession>`,跟踪 `pending_sessions`,合并同一 id 的并发加载,并测试加载期间关闭的行为。 [English](2026-06-20-single-session-acp-bridge.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index eae5710a9a..1861fcb961 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-truncate-interrupted-turns.md: af18618ad4c41af125e37c51b9fd971dd8eae64e -2026-06-20-truncate-interrupted-turns.zh.md: 4deebfcc7417dd860c655813230d3a79b2d98c50 +2026-06-20-truncate-interrupted-turns.zh.md: a6c7d1df32d444d301635665afb4fe3b0c4ac5af diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index 4deebfcc74..a6c7d1df32 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -1,6 +1,6 @@ # Agent Note: 加载时截断被中断的最终轮次 -Status: rejected — a single turn can contain substantial real work, including many steps and large tool output. Preserving interrupted turns is preferable to silently dropping that tail on load. +Status: rejected — 单个 turn 可以包含大量真实工作,包括多个 steps 和大量工具输出。保留被中断的 turns,优于在加载时静默丢弃这段尾部。 [English](2026-06-20-truncate-interrupted-turns.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index fa0ab68ede..61d489a85c 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-unimplemented-subagent-vocabulary.md: f99a33163b48735f9634b5bb3dcac5c24eb893f8 -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: a1a2f35ad31a0c40d82cff00df582a488619c241 +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 15368b0a8abb2980af255827498803b8bb6645f7 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index a1a2f35ad3..15368b0a8a 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -1,6 +1,6 @@ # Agent Note: 裁剪未实现的 subagent seam 词汇 -Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below records the decision-time state. +Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`toolFilter`、`sendMessage`/`resume`)是有意保留的接口面:该接缝按设计先于实现声明完整的预期契约,使提供方与消费方沿稳定形状演进,而非针对每项能力重新协商。下方的消费方证据分析记录了决策时的状态。 [English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 @@ -19,7 +19,7 @@ Status: rejected — the deferred capability vocabulary (`outputSchema`/`structu **保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个工具默认值,而非删除正在工作的强制逻辑。 -审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) 恰好记录了这种形态从 bash executor 中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent-observe-enrich Agent Note](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) 的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 +审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md)恰好记录了这种形态从 bash executor 中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent 观测/丰富化 Agent Note](../../implemented/feature/2026-06-30-subagent-observe-enrich.md)的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 这是[从持久化 seam 裁剪死方法](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须为无人声明的成员,甚至更弱,因为这里连一个实现都没有。 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 34f8bdb0c9..53028cf3a3 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-collapse-workflow-to-foreground-core.md: 629e2140523c3ae7caf533de99821206d05f1b8e -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 3cdaeebe42ca799a48c5321b70c07e6151d8f98d +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 79eb61653e812647082ba6ebcb7f0636db6e0240 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 3cdaeebe42..79eb61653e 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -1,6 +1,6 @@ # Agent Note: 将工作流收缩至已使用的前台核心 -Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. +Status: rejected — 工作流进度是有意设计的观测接口面;应通过消费方使其发挥作用,而非删除它。 [English](2026-07-12-collapse-workflow-to-foreground-core.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml index d1a52c0efb..c28461d08b 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-prune-unused-skill-registry-surface.md: 5a13effa04a6cd9954741a0a33ebc6fc3512fab8 -2026-07-12-prune-unused-skill-registry-surface.zh.md: 4b25c03d740f4fda1696629d56b88057a055f0af +2026-07-12-prune-unused-skill-registry-surface.zh.md: 6d917fadf3d408c934e88b2bd0a9c7691632f91b diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md index 4b25c03d74..6d917fadf3 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md @@ -1,6 +1,6 @@ # Agent Note: 裁剪 skill 注册表中未使用的接口 -Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. +Status: rejected — 直接在运行时注册 skill 是为第三方插件保留的有意扩展路径。 [English](2026-07-12-prune-unused-skill-registry-surface.md) | 中文 From d364adc93bf1dd3210bbdc5ec3060575f5b4ab61 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:22:50 +0800 Subject: [PATCH 021/113] docs(i18n): distinguish rounds from turns --- docs/glossary.i18n.yaml | 2 +- docs/glossary.zh.md | 12 ++++++------ docs/i18n/terminology.md | 1 + docs/testing.i18n.yaml | 2 +- docs/testing.zh.md | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 6352220975..9724877d71 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write glossary.md: c1931c0e0c630d05f5bd4fc3f30720f858f1175e -glossary.zh.md: f8f3c9489a8be94de7fb3f021dd70bea7a399d73 +glossary.zh.md: 951abebc162e2456211437fcd5b27dd57783e9b6 diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index f8f3c9489a..951abebc16 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -20,9 +20,9 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S ## 目标 -- **目标**:附着在现有会话上的单个持久完成目标,带有按修订号演进的 `active` / `paused` / `blocked` / `complete` 阶段和目标回合上限;`blocked` 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。 -- **目标回合**:为当前目标接纳的一次续行周期。同会话驱动器将目标回合具体化为一个来源为目标的[轮次](#turn),其中可以包含多个步骤;同一会话中无关的人类轮次不消耗目标回合上限。<a id="goal-round"></a> -- **目标激活**:续行消费方接纳下一个目标回合的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此恢复和 fork 后,必须由人类随后通过 `/goal` 或模型工具授权恢复变更,自动工作才可开始。 +- **目标**:附着在现有会话上的单个持久完成目标,带有按修订号演进的 `active` / `paused` / `blocked` / `complete` 阶段和 Goal Round 上限;`blocked` 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。 +- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的[轮次](#turn),其中包含一个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限。<a id="goal-round"></a> +- **目标激活**:续行消费方接纳下一个 Goal Round 的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此恢复和 fork 后,必须由人类随后通过 `/goal` 或模型工具授权恢复变更,自动工作才可开始。 ## 人类命令 @@ -34,10 +34,10 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S - **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a> - **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含一个或多个步骤。<a id="step"></a> -- **回合**:包含一个轮次的外层策略迭代,例如一个[目标回合](#goal-round)或一次全新 agent Ralph 尝试。回合计数器属于该策略,并不统计会话内的每个轮次。<a id="round"></a> +- **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。<a id="round"></a> ## Ralph - **Ralph 循环**:一次面向不可变目标的前台全新 agent 工作流运行。它是由工作流和 subagent 原语组合而成的面向模型的工具策略,不是同会话目标、agent loop(智能体循环)模式、调度器或通用工作流脚本功能。<a id="ralph-loop"></a> -- **Ralph 回合**:[Ralph 循环](#ralph-loop)中的一个全新子会话。子会话不接收父会话或此前子会话的对话种子;共享工作区和一份有界的 [Ralph 交接](#ralph-handoff)承载跨回合状态。<a id="ralph-round"></a> -- **Ralph 交接**:从一个仍需继续的 Ralph 回合传给下一回合的规范化、有界结构化报告,包含状态、摘要、证据、后续步骤和阻塞说明。它补充共享工作区,而不取代工作区的权威地位。<a id="ralph-handoff"></a> +- **Ralph Round**:[Ralph 循环](#ralph-loop)中的一个全新子会话。子会话不接收父会话或此前子会话的对话种子;共享工作区和一份有界的 [Ralph 交接](#ralph-handoff)承载跨 Round 的状态。<a id="ralph-round"></a> +- **Ralph 交接**:从一个仍需继续的 Ralph Round 传给下一个 Ralph Round 的规范化、有界结构化报告,包含状态、摘要、证据、后续步骤和阻塞说明。它补充共享工作区,而不取代工作区的权威地位。<a id="ralph-handoff"></a> diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 74ce7ad969..287fbbc572 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -52,6 +52,7 @@ | loader | loader | | | | | manifest | manifest | manifest(元数据清单) | | | | monorepo | monorepo | | | | +| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个 Turn,一个 Turn 包含一个或多个 Step。 | | schema | schema | | | | | schema DSL | schema DSL | | | | | seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 8ef18d76b6..9d1553ebbe 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write testing.md: 1de3a0754dadbad1f36a9fc8c19b8ba77071bd54 -testing.zh.md: c557dfd91bca436a9c969cde6e2a0b24237e3146 +testing.zh.md: f6ec709862879192bb513f4701227fedfa7ecb66 diff --git a/docs/testing.zh.md b/docs/testing.zh.md index c557dfd91b..f6ec709862 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -13,7 +13,7 @@ ## 带密钥策略:推理在这里很便宜 -我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、多轮对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 +我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、包含多个轮次的对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 ## 优先使用真实实现而非 mock From 2f9f5ecbb7490d268e256cf8373db1f96465016e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:25:54 +0800 Subject: [PATCH 022/113] docs(i18n): normalize turn and step terms --- docs/core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- docs/core-data-structures/core.i18n.yaml | 2 +- docs/core-data-structures/core.zh.md | 16 ++++++++-------- .../core-data-structures/llm-streaming.i18n.yaml | 2 +- docs/core-data-structures/llm-streaming.zh.md | 4 ++-- docs/core-data-structures/persistence.i18n.yaml | 2 +- docs/core-data-structures/persistence.zh.md | 6 +++--- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 5133356f6e..17801a0c9c 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: 592e215e7efe69f0dd6099d3d510847cc732e641 +compaction.zh.md: 3a49976c34b99d583647ca6a20a875faf319ff4a diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 592e215e7e..3a49976c34 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败 step 关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的 step 重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个 turn,因此一个过大 turn 中较早关闭的 step 可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与孤立结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 0cb17efb9c..6eddadba2b 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write core.md: 7d0f9503dfd4c47f185d2bcef84528145d66fa70 -core.zh.md: 4bc31681d96483a300cc7a0ccfb5e489ba34f691 +core.zh.md: dd9219ee682a770917e68e87e49d06df09e54456 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 4bc31681d9..dd9219ee68 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -20,7 +20,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | | [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | -| [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 round 归属 | +| [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | @@ -357,7 +357,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及 turn enclosure 不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## Agent 句柄 @@ -454,11 +454,11 @@ interface Agent { } ``` -`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越 turn 关闭、其持久化检查点以及连续的排队 turn;它不能证明某个 turn 仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`;该值在发布 `turn/end` 前退役。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。Turn 和 step 边界是持久会话事件,而不是 agent emit。 +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 ## 发起 Agent @@ -486,7 +486,7 @@ interface HookContext { } ``` -`agent/prompt-submit` 返回 `PromptDecision`(允许 turn 已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零 step turn): +`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次): ```ts type-equiv /** @@ -503,7 +503,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` 返回 `ContinuationDecision`(step 有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一 turn 中下一 step 的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): +`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ @@ -512,14 +512,14 @@ type ContinuationDecision = | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` -`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、turn signal 以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: +`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: ```ts type-equiv /** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` -它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的 step,而 `fail` 在 `turn/end` 上保留结构化失败: +它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败: ```ts type-equiv /** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index d22cd0c4a3..11fc83975d 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb -llm-streaming.zh.md: 36fb640a030645861a163f6b33b3c0b60cf5ed8e +llm-streaming.zh.md: 9942b571073b2c04c7f38291c133e1fd19de4dd0 diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 36fb640a03..9942b57107 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -59,8 +59,8 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的 step,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为 turn 错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 -- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的 step;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 76ccae0b33..14b478c66f 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write persistence.md: d4bb4ed1b65eb74ff03483bf3a71a1736300834e -persistence.zh.md: ea0bff84bb939f0eab370375509e920ea45eb404 +persistence.zh.md: 3000336af79762f1a40e887cad7d69fb4c771e8a diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index ea0bff84bb..3000336af7 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -8,7 +8,7 @@ ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通 turn 的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭 turn 作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭 turn 之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 +`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通轮次的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭轮次作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭轮次之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 ## 崩溃恢复保留被中断的轮次 @@ -16,7 +16,7 @@ ## `SessionLocation`——可选的逐会话制品目标 -`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前未 flush turn 的文件;它是位置提示,不是授权或新鲜度保证。 +`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 ```ts type-equiv /** @@ -104,7 +104,7 @@ interface CreateSessionOptions { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、中断 turn 恢复以及读取/回放路径。 +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 共享同一磁盘会话的多个后端通过[共享持久化写入协调器](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 From be7d84813e053e5352d1ed720ab8428ea18023cc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:41:44 +0800 Subject: [PATCH 023/113] docs(i18n): preserve Turn and Step terms --- .../core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- docs/core-data-structures/core.i18n.yaml | 2 +- docs/core-data-structures/core.zh.md | 26 +++++++++---------- .../llm-streaming.i18n.yaml | 2 +- docs/core-data-structures/llm-streaming.zh.md | 4 +-- .../persistence.i18n.yaml | 2 +- docs/core-data-structures/persistence.zh.md | 10 +++---- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 17801a0c9c..c4924d7c3d 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: 3a49976c34b99d583647ca6a20a875faf319ff4a +compaction.zh.md: aacb5def6701e8aece932e394290c8f373c7d60c diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 3a49976c34..aacb5def67 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的 Step(步骤)关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的 Step 重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个 Turn(轮次),因此一个过大的 Turn 中较早关闭的 Step 可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与孤立结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 6eddadba2b..db8f7fe8fb 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write core.md: 7d0f9503dfd4c47f185d2bcef84528145d66fa70 -core.zh.md: dd9219ee682a770917e68e87e49d06df09e54456 +core.zh.md: 934234eb469dbf4a6f8522742df3a2e9b08cc15a diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index dd9219ee68..934234eb46 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -2,15 +2,15 @@ [English](core.md) | 中文 -本目录编目 DeepSeek Harness 的**数据结构**:每个核心类型代表什么、它的字面形状,以及完整细节在哪里。它与 [architecture.md](../architecture.md) 互补——后者描述*行为*(服务映射、会话/轮次/步骤生命周期、事件分类体系);本页描述行为所操作的*词汇*。 +本目录编目 DeepSeek Harness 的**数据结构**:每个核心类型代表什么、它的字面形状,以及完整细节在哪里。它与 [architecture.md](../architecture.md) 互补——后者描述*行为*(服务映射、会话/Turn(轮次)/Step(步骤)生命周期、事件分类体系);本页描述行为所操作的*词汇*。 ## 什么算"核心" -harness 是一个微内核:一个极小的核心加上众多插件。大多数类型属于某一个插件或某一项能力。但有少数类型构成**主干**——agent loop(智能体循环)及其事件在*每一个*轮次中使用的语言,无论加载了哪些可选插件。这些就是"核心"。 +harness 是一个微内核:一个极小的核心加上众多插件。大多数类型属于某一个插件或某一项能力。但有少数类型构成**主干**——agent loop(智能体循环)及其事件在*每一个* Turn 中使用的语言,无论加载了哪些可选插件。这些就是"核心"。 精确地说,一个数据结构是**核心**的,当且仅当满足以下条件之一: -1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** +1. 它流经 agent loop 主干——循环在每个 Turn 中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** 2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `SchemaSpec`/`InferArgs` DSL、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 @@ -22,7 +22,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | -| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | +| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、Turn 封闭不变式 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取与关系追踪 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | @@ -269,7 +269,7 @@ interface FinishReasonMap { `FinishReason = FinishReasonMap[keyof FinishReasonMap]`。`TokenUsage`(逐调用计量,含不相交的缓存字段)详见 [llm-streaming.md](llm-streaming.md)。 -`GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环每一步组装请求的一部分: +`GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环在每个 Step 中组装请求的一部分: ```ts type-equiv /** @@ -295,7 +295,7 @@ interface ToolSchema { `agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 +在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在 Turn 的第一个 Step 是最新的 `user/message`,在后续 Step 是上一个 Step 的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 @@ -357,7 +357,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及 Turn 封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## Agent 句柄 @@ -454,11 +454,11 @@ interface Agent { } ``` -`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越 Turn 关闭、其持久化检查点以及连续的排队 Turn;它不能证明某个 Turn 仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`;该值在发布 `turn/end` 前退役。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。Turn 和 Step 边界是持久会话事件,而不是 agent emit。 ## 发起 Agent @@ -486,7 +486,7 @@ interface HookContext { } ``` -`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次): +`agent/prompt-submit` 返回 `PromptDecision`(允许该 Turn 已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零 Step 的 Turn): ```ts type-equiv /** @@ -503,7 +503,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): +`agent/turn-continuation` 返回 `ContinuationDecision`(Step 有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一 Turn 中下一个 Step 的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ @@ -512,14 +512,14 @@ type ContinuationDecision = | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` -`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: +`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、Turn 信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: ```ts type-equiv /** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` -它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败: +它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的 Step,而 `fail` 在 `turn/end` 上保留结构化失败: ```ts type-equiv /** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 11fc83975d..b7b9af9d43 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb -llm-streaming.zh.md: 9942b571073b2c04c7f38291c133e1fd19de4dd0 +llm-streaming.zh.md: 86665461a83f79342e9d619de6bd010585372baa diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 9942b57107..86665461a8 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -59,8 +59,8 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 -- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的 Step(步骤),再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为 Turn(轮次)错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的 Step;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 14b478c66f..c01787b731 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write persistence.md: d4bb4ed1b65eb74ff03483bf3a71a1736300834e -persistence.zh.md: 3000336af79762f1a40e887cad7d69fb4c771e8a +persistence.zh.md: 6796a10a9ad7459eec6734c64d056ec739dc4010 diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 3000336af7..6796a10a9a 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -8,15 +8,15 @@ ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通轮次的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭轮次作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭轮次之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 +`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通 Turn(轮次)的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭 Turn 作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭 Turn 之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 -## 崩溃恢复保留被中断的轮次 +## 崩溃恢复保留被中断的 Turn -后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次闭合不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 +后端重新加载一个在 Turn 中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个 Turn 可能非常庞大(许多 Step(步骤)、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留 Turn,保持日志平衡与 Turn 封闭不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 ## `SessionLocation`——可选的逐会话制品目标 -`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 +`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的 Turn;它是位置提示,不是授权或新鲜度保证。 ```ts type-equiv /** @@ -104,7 +104,7 @@ interface CreateSessionOptions { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断 Turn 的恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 共享同一磁盘会话的多个后端通过[共享持久化写入协调器](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 From 5f67b99e72b9a35e0b8f35f5c92141f0acadcb38 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:53:14 +0800 Subject: [PATCH 024/113] docs(i18n): align Turn and Step terminology --- docs/defensive-patterns.i18n.yaml | 2 +- docs/defensive-patterns.zh.md | 4 ++-- docs/glossary.i18n.yaml | 2 +- docs/glossary.zh.md | 8 ++++---- docs/i18n/terminology.md | 4 ++-- docs/testing.i18n.yaml | 2 +- docs/testing.zh.md | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index b39cad3e24..39db53200c 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write defensive-patterns.md: 349b916df6f7544300dacd578acf42668d9436ac -defensive-patterns.zh.md: 19565f54595195a52d1b49ff487294945171ae2d +defensive-patterns.zh.md: 7c99290854e2bbdb4237853717f175d096ec79d9 diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index 19565f5459..7c99290854 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -10,11 +10,11 @@ ## 跨 seam 契约两侧都要遵守 -当一个接口文档记录了两种合法的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须同时处理两种路径,而不是只处理第一个实现恰好使用的那种。依赖库的适配器可能无法在流中途抛出异常,只能走带内路径;如果 agent loop(智能体循环)只捕获抛出的异常,就会把提供方的 401 错误变成一个正常完成的轮次。请在类型定义处记录契约;通过真实消费方测试每个分支。 +当一个接口文档记录了两种合法的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须同时处理两种路径,而不是只处理第一个实现恰好使用的那种。依赖库的适配器可能无法在流中途抛出异常,只能走带内路径;如果 agent loop(智能体循环)只捕获抛出的异常,就会把提供方的 401 错误变成一个正常完成的 Turn(轮次)。请在类型定义处记录契约;通过真实消费方测试每个分支。 ## 异步状态不是同步状态 -`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作单次发送的结果:多个排队发送会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 +`agent.send()` 不会在返回前翻转状态;后台任务的完成与 Turn 边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作单次发送的结果:多个排队发送会在同一个 `running` 区间内连续运行多个 Turn,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 ## Dispose 必须达到静止,而不仅仅是请求停止 diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 9724877d71..9ccbe4467d 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write glossary.md: c1931c0e0c630d05f5bd4fc3f30720f858f1175e -glossary.zh.md: 951abebc162e2456211437fcd5b27dd57783e9b6 +glossary.zh.md: bd44efa1222bde5a4281cbb005543fa3cc6691b4 diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index 951abebc16..bd44efa122 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -21,7 +21,7 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S ## 目标 - **目标**:附着在现有会话上的单个持久完成目标,带有按修订号演进的 `active` / `paused` / `blocked` / `complete` 阶段和 Goal Round 上限;`blocked` 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。 -- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的[轮次](#turn),其中包含一个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限。<a id="goal-round"></a> +- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的 [Turn(轮次)](#turn),其中包含一个或多个 Step(步骤);同一会话中无关的人类 Turn 不消耗 Goal Round 上限。<a id="goal-round"></a> - **目标激活**:续行消费方接纳下一个 Goal Round 的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此恢复和 fork 后,必须由人类随后通过 `/goal` 或模型工具授权恢复变更,自动工作才可开始。 ## 人类命令 @@ -32,9 +32,9 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S ## 循环层级 -- **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a> -- **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含一个或多个步骤。<a id="step"></a> -- **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。<a id="round"></a> +- **Turn**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a> +- **Step**:一次模型请求,以及由模型响应引发的工具执行;一个 Turn 包含一个或多个 Step。<a id="step"></a> +- **Round**:承载一个 Turn 的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个 Turn。<a id="round"></a> ## Ralph diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 287fbbc572..b0b44f24cc 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -58,11 +58,13 @@ | seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | | skill | skill | skill(技能) | | | | spawn | spawn | | | | +| Step | Step | Step(步骤) | | 领域层级术语;普通流程或操作步骤不在此列,按中文语境翻译。 | | steering | steering | steering(中途引导) | | | | task id | task id | | 任务 id | 保留英文 | | subagent | subagent | | | | | thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` | | transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 | +| Turn | Turn | Turn(轮次) | | 领域层级术语;普通非领域用法(如轮流、转向或往返)按中文语境翻译。 | | waterfall | waterfall | waterfall(瀑布式事件) | | | | wheel | wheel 包 | | | Python 打包格式 | | worktree | worktree | | | git 工作区概念 | @@ -164,7 +166,6 @@ | spine | 主干 | | | | | staged | 暂存 | | | 沿用 git 官方中文翻译 | | stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` | -| step | 步骤 | | | | | stream | 流 | | | | | streaming | 流式输出 | | | | | structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) | @@ -176,7 +177,6 @@ | tool result | 工具结果 | | | | | tool schema | 工具 schema | | | | | toolkit | 工具包 | | | | -| turn | 轮次 | | | | | VFS | VFS | 虚拟文件系统(VFS) | | | | typecheck | 类型检查 | | | | | vocabulary | 词汇 | | | | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 89e63f7fef..f94a618dc6 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write testing.md: 5a18397ba2431a4c4f2595d32d9de6fe3ddeb6f4 -testing.zh.md: 19ee4aa6abffc13c35b1933e2af0ed38eef5c7e6 +testing.zh.md: d9f1fca745b0f545f0b1904a2d3029649568d18d diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 19ee4aa6ab..d9f1fca745 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -13,13 +13,13 @@ ## 带密钥策略:推理在这里很便宜 -我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、包含多个轮次的对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 +我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、包含多个 Turn(轮次)的对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 ## 优先使用真实实现而非 mock 只 mock 开销高或不确定的边界(LLM(大语言模型)适配器、网络、时钟);下游一切保持真实。手写替身只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言。桥接工具调用测试将脚本化 mock 模型与真实工具和执行器配合使用:`makeBridgeHarness({ withBash: true })` 接入 `dsh-bash-local` 与 `dsh-tool-bash`,然后运行 `echo`。 -恢复测试按步骤区分分片前与分片后的失败,并证明失败分片不会派生出消息或工具副作用。覆盖耗尽、取消、策略组合、持久化、状态、协议计数、会关闭传输的空闲超时,以及交付的 Loader 组合。 +恢复测试按 Step(步骤)区分分片前与分片后的失败,并证明失败分片不会派生出消息或工具副作用。覆盖耗尽、取消、策略组合、持久化、状态、协议计数、会关闭传输的空闲超时,以及交付的 Loader 组合。 ## 验证外部世界,而非自我报告 From eb25da2edf026d250903ae508d732e22b473816c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:03:37 +0800 Subject: [PATCH 025/113] Revert "docs(i18n): preserve Turn and Step terms" This reverts commit 3f0e2091122aebe83c802f25e330faa7c9c7b7f9. --- .../core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- docs/core-data-structures/core.i18n.yaml | 2 +- docs/core-data-structures/core.zh.md | 26 +++++++++---------- .../llm-streaming.i18n.yaml | 2 +- docs/core-data-structures/llm-streaming.zh.md | 4 +-- .../persistence.i18n.yaml | 2 +- docs/core-data-structures/persistence.zh.md | 10 +++---- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index c4924d7c3d..17801a0c9c 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: aacb5def6701e8aece932e394290c8f373c7d60c +compaction.zh.md: 3a49976c34b99d583647ca6a20a875faf319ff4a diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index aacb5def67..3a49976c34 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的 Step(步骤)关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的 Step 重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个 Turn(轮次),因此一个过大的 Turn 中较早关闭的 Step 可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与孤立结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index db8f7fe8fb..6eddadba2b 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write core.md: 7d0f9503dfd4c47f185d2bcef84528145d66fa70 -core.zh.md: 934234eb469dbf4a6f8522742df3a2e9b08cc15a +core.zh.md: dd9219ee682a770917e68e87e49d06df09e54456 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 934234eb46..dd9219ee68 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -2,15 +2,15 @@ [English](core.md) | 中文 -本目录编目 DeepSeek Harness 的**数据结构**:每个核心类型代表什么、它的字面形状,以及完整细节在哪里。它与 [architecture.md](../architecture.md) 互补——后者描述*行为*(服务映射、会话/Turn(轮次)/Step(步骤)生命周期、事件分类体系);本页描述行为所操作的*词汇*。 +本目录编目 DeepSeek Harness 的**数据结构**:每个核心类型代表什么、它的字面形状,以及完整细节在哪里。它与 [architecture.md](../architecture.md) 互补——后者描述*行为*(服务映射、会话/轮次/步骤生命周期、事件分类体系);本页描述行为所操作的*词汇*。 ## 什么算"核心" -harness 是一个微内核:一个极小的核心加上众多插件。大多数类型属于某一个插件或某一项能力。但有少数类型构成**主干**——agent loop(智能体循环)及其事件在*每一个* Turn 中使用的语言,无论加载了哪些可选插件。这些就是"核心"。 +harness 是一个微内核:一个极小的核心加上众多插件。大多数类型属于某一个插件或某一项能力。但有少数类型构成**主干**——agent loop(智能体循环)及其事件在*每一个*轮次中使用的语言,无论加载了哪些可选插件。这些就是"核心"。 精确地说,一个数据结构是**核心**的,当且仅当满足以下条件之一: -1. 它流经 agent loop 主干——循环在每个 Turn 中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** +1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** 2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `SchemaSpec`/`InferArgs` DSL、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 @@ -22,7 +22,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | -| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、Turn 封闭不变式 | +| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、轮次封闭不变式 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取与关系追踪 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | @@ -269,7 +269,7 @@ interface FinishReasonMap { `FinishReason = FinishReasonMap[keyof FinishReasonMap]`。`TokenUsage`(逐调用计量,含不相交的缓存字段)详见 [llm-streaming.md](llm-streaming.md)。 -`GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环在每个 Step 中组装请求的一部分: +`GenerateOptions.tools` 携带 `ToolSchema`——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环每一步组装请求的一部分: ```ts type-equiv /** @@ -295,7 +295,7 @@ interface ToolSchema { `agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型或采样参数。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在 Turn 的第一个 Step 是最新的 `user/message`,在后续 Step 是上一个 Step 的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 +在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视此类型的精确定义——出于缓存目的,哪些字段确实属于 epoch 层级(`model` 肯定属于;采样标量目前出于谨慎放在这里),以及适配器需要时,提供方特有的额外项(推理选项、额外 body 参数)应归属何处。 @@ -357,7 +357,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及 Turn 封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## Agent 句柄 @@ -454,11 +454,11 @@ interface Agent { } ``` -`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越 Turn 关闭、其持久化检查点以及连续的排队 Turn;它不能证明某个 Turn 仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`;该值在发布 `turn/end` 前退役。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。Turn 和 Step 边界是持久会话事件,而不是 agent emit。 +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 ## 发起 Agent @@ -486,7 +486,7 @@ interface HookContext { } ``` -`agent/prompt-submit` 返回 `PromptDecision`(允许该 Turn 已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零 Step 的 Turn): +`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次): ```ts type-equiv /** @@ -503,7 +503,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` 返回 `ContinuationDecision`(Step 有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一 Turn 中下一个 Step 的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): +`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ @@ -512,14 +512,14 @@ type ContinuationDecision = | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` -`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、Turn 信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: +`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: ```ts type-equiv /** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` -它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的 Step,而 `fail` 在 `turn/end` 上保留结构化失败: +它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败: ```ts type-equiv /** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index b7b9af9d43..11fc83975d 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb -llm-streaming.zh.md: 86665461a83f79342e9d619de6bd010585372baa +llm-streaming.zh.md: 9942b571073b2c04c7f38291c133e1fd19de4dd0 diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 86665461a8..9942b57107 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -59,8 +59,8 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的 Step(步骤),再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为 Turn(轮次)错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 -- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的 Step;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index c01787b731..14b478c66f 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write persistence.md: d4bb4ed1b65eb74ff03483bf3a71a1736300834e -persistence.zh.md: 6796a10a9ad7459eec6734c64d056ec739dc4010 +persistence.zh.md: 3000336af79762f1a40e887cad7d69fb4c771e8a diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 6796a10a9a..3000336af7 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -8,15 +8,15 @@ ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通 Turn(轮次)的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭 Turn 作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭 Turn 之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 +`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通轮次的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭轮次作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭轮次之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 -## 崩溃恢复保留被中断的 Turn +## 崩溃恢复保留被中断的轮次 -后端重新加载一个在 Turn 中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个 Turn 可能非常庞大(许多 Step(步骤)、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留 Turn,保持日志平衡与 Turn 封闭不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 +后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次闭合不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 ## `SessionLocation`——可选的逐会话制品目标 -`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的 Turn;它是位置提示,不是授权或新鲜度保证。 +`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 ```ts type-equiv /** @@ -104,7 +104,7 @@ interface CreateSessionOptions { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/load/list),并通过 `runPersistenceContract`,证明该 seam 确实与后端无关: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断 Turn 的恢复以及读取/回放路径。 +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 共享同一磁盘会话的多个后端通过[共享持久化写入协调器](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 From 46e7330f8fba783f83c2a3523a8e884a7b34ea62 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:20:43 +0800 Subject: [PATCH 026/113] docs(i18n): use Chinese turn and step terms --- docs/defensive-patterns.i18n.yaml | 2 +- docs/defensive-patterns.zh.md | 4 ++-- docs/glossary.i18n.yaml | 2 +- docs/glossary.zh.md | 8 ++++---- docs/i18n/terminology.md | 6 +++--- docs/testing.i18n.yaml | 2 +- docs/testing.zh.md | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 39db53200c..b39cad3e24 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write defensive-patterns.md: 349b916df6f7544300dacd578acf42668d9436ac -defensive-patterns.zh.md: 7c99290854e2bbdb4237853717f175d096ec79d9 +defensive-patterns.zh.md: 19565f54595195a52d1b49ff487294945171ae2d diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index 7c99290854..19565f5459 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -10,11 +10,11 @@ ## 跨 seam 契约两侧都要遵守 -当一个接口文档记录了两种合法的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须同时处理两种路径,而不是只处理第一个实现恰好使用的那种。依赖库的适配器可能无法在流中途抛出异常,只能走带内路径;如果 agent loop(智能体循环)只捕获抛出的异常,就会把提供方的 401 错误变成一个正常完成的 Turn(轮次)。请在类型定义处记录契约;通过真实消费方测试每个分支。 +当一个接口文档记录了两种合法的信号方式时——例如适配器可以通过从 `stream()` 抛出异常来报告失败,也可以通过以 `finish {kind:'error'|'aborted'}` 分片结束流来报告——消费方必须同时处理两种路径,而不是只处理第一个实现恰好使用的那种。依赖库的适配器可能无法在流中途抛出异常,只能走带内路径;如果 agent loop(智能体循环)只捕获抛出的异常,就会把提供方的 401 错误变成一个正常完成的轮次。请在类型定义处记录契约;通过真实消费方测试每个分支。 ## 异步状态不是同步状态 -`agent.send()` 不会在返回前翻转状态;后台任务的完成与 Turn 边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作单次发送的结果:多个排队发送会在同一个 `running` 区间内连续运行多个 Turn,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 +`agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作单次发送的结果:多个排队发送会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 ## Dispose 必须达到静止,而不仅仅是请求停止 diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 9ccbe4467d..9724877d71 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write glossary.md: c1931c0e0c630d05f5bd4fc3f30720f858f1175e -glossary.zh.md: bd44efa1222bde5a4281cbb005543fa3cc6691b4 +glossary.zh.md: 951abebc162e2456211437fcd5b27dd57783e9b6 diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index bd44efa122..951abebc16 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -21,7 +21,7 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S ## 目标 - **目标**:附着在现有会话上的单个持久完成目标,带有按修订号演进的 `active` / `paused` / `blocked` / `complete` 阶段和 Goal Round 上限;`blocked` 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。 -- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的 [Turn(轮次)](#turn),其中包含一个或多个 Step(步骤);同一会话中无关的人类 Turn 不消耗 Goal Round 上限。<a id="goal-round"></a> +- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的[轮次](#turn),其中包含一个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限。<a id="goal-round"></a> - **目标激活**:续行消费方接纳下一个 Goal Round 的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此恢复和 fork 后,必须由人类随后通过 `/goal` 或模型工具授权恢复变更,自动工作才可开始。 ## 人类命令 @@ -32,9 +32,9 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S ## 循环层级 -- **Turn**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a> -- **Step**:一次模型请求,以及由模型响应引发的工具执行;一个 Turn 包含一个或多个 Step。<a id="step"></a> -- **Round**:承载一个 Turn 的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个 Turn。<a id="round"></a> +- **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a> +- **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含一个或多个步骤。<a id="step"></a> +- **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。<a id="round"></a> ## Ralph diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index b0b44f24cc..6fc4b556fe 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -52,19 +52,17 @@ | loader | loader | | | | | manifest | manifest | manifest(元数据清单) | | | | monorepo | monorepo | | | | -| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个 Turn,一个 Turn 包含一个或多个 Step。 | +| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,一个轮次包含一个或多个步骤。 | | schema | schema | | | | | schema DSL | schema DSL | | | | | seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | | skill | skill | skill(技能) | | | | spawn | spawn | | | | -| Step | Step | Step(步骤) | | 领域层级术语;普通流程或操作步骤不在此列,按中文语境翻译。 | | steering | steering | steering(中途引导) | | | | task id | task id | | 任务 id | 保留英文 | | subagent | subagent | | | | | thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` | | transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 | -| Turn | Turn | Turn(轮次) | | 领域层级术语;普通非领域用法(如轮流、转向或往返)按中文语境翻译。 | | waterfall | waterfall | waterfall(瀑布式事件) | | | | wheel | wheel 包 | | | Python 打包格式 | | worktree | worktree | | | git 工作区概念 | @@ -166,6 +164,7 @@ | spine | 主干 | | | | | staged | 暂存 | | | 沿用 git 官方中文翻译 | | stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` | +| step | 步骤 | | | | | stream | 流 | | | | | streaming | 流式输出 | | | | | structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) | @@ -177,6 +176,7 @@ | tool result | 工具结果 | | | | | tool schema | 工具 schema | | | | | toolkit | 工具包 | | | | +| turn | 轮次 | | | | | VFS | VFS | 虚拟文件系统(VFS) | | | | typecheck | 类型检查 | | | | | vocabulary | 词汇 | | | | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index f94a618dc6..89e63f7fef 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write testing.md: 5a18397ba2431a4c4f2595d32d9de6fe3ddeb6f4 -testing.zh.md: d9f1fca745b0f545f0b1904a2d3029649568d18d +testing.zh.md: 19ee4aa6abffc13c35b1933e2af0ed38eef5c7e6 diff --git a/docs/testing.zh.md b/docs/testing.zh.md index d9f1fca745..19ee4aa6ab 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -13,13 +13,13 @@ ## 带密钥策略:推理在这里很便宜 -我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、包含多个 Turn(轮次)的对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 +我们是 DeepSeek,不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent(智能体)能对接真实模型正常工作。覆盖文件写入提示词、包含多个轮次的对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。 ## 优先使用真实实现而非 mock 只 mock 开销高或不确定的边界(LLM(大语言模型)适配器、网络、时钟);下游一切保持真实。手写替身只能证明桥接层在搬运字节,不能证明交付的工具行为符合断言。桥接工具调用测试将脚本化 mock 模型与真实工具和执行器配合使用:`makeBridgeHarness({ withBash: true })` 接入 `dsh-bash-local` 与 `dsh-tool-bash`,然后运行 `echo`。 -恢复测试按 Step(步骤)区分分片前与分片后的失败,并证明失败分片不会派生出消息或工具副作用。覆盖耗尽、取消、策略组合、持久化、状态、协议计数、会关闭传输的空闲超时,以及交付的 Loader 组合。 +恢复测试按步骤区分分片前与分片后的失败,并证明失败分片不会派生出消息或工具副作用。覆盖耗尽、取消、策略组合、持久化、状态、协议计数、会关闭传输的空闲超时,以及交付的 Loader 组合。 ## 验证外部世界,而非自我报告 From 94165953cf48a7478ccf8b16b43086770716643f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:31:38 +0800 Subject: [PATCH 027/113] docs(i18n): clarify zero-step turn hierarchy --- docs/i18n/terminology.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 6fc4b556fe..0087f967f8 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -52,7 +52,7 @@ | loader | loader | | | | | manifest | manifest | manifest(元数据清单) | | | | monorepo | monorepo | | | | -| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,一个轮次包含一个或多个步骤。 | +| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 | | schema | schema | | | | | schema DSL | schema DSL | | | | | seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | From a4d6c302f60b25683475ddbd05679f77eeecaa94 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:37:01 +0800 Subject: [PATCH 028/113] docs(i18n): align turn and step terminology --- .../2026-06-30-event-domain-semantics.i18n.yaml | 2 +- .../architecture/2026-06-30-event-domain-semantics.zh.md | 4 ++-- .../implemented/feature/2026-06-30-hook-bridges.i18n.yaml | 2 +- .../implemented/feature/2026-06-30-hook-bridges.zh.md | 2 +- .../feature/2026-06-30-hook-protocol-lib.i18n.yaml | 2 +- .../feature/2026-06-30-hook-protocol-lib.zh.md | 2 +- .../feature/2026-06-30-interception-seams.i18n.yaml | 2 +- .../feature/2026-06-30-interception-seams.zh.md | 2 +- .../implemented/feature/2026-07-06-sandbox.i18n.yaml | 2 +- .../notes/implemented/feature/2026-07-06-sandbox.zh.md | 4 ++-- .../feature/2026-07-07-session-prefix.i18n.yaml | 2 +- .../implemented/feature/2026-07-07-session-prefix.zh.md | 4 ++-- .../2026-06-20-core-data-structures-catalog.i18n.yaml | 2 +- .../process/2026-06-20-core-data-structures-catalog.zh.md | 2 +- .../2026-07-03-documentation-graph-atlas.i18n.yaml | 2 +- .../process/2026-07-03-documentation-graph-atlas.zh.md | 2 +- ...026-06-20-collapse-trace-only-session-events.i18n.yaml | 2 +- .../2026-06-20-collapse-trace-only-session-events.zh.md | 2 +- .../2026-06-20-public-agent-stop-surface.i18n.yaml | 2 +- .../2026-06-20-public-agent-stop-surface.zh.md | 8 ++++---- ...26-06-20-remove-agent-boundary-mirror-events.i18n.yaml | 2 +- .../2026-06-20-remove-agent-boundary-mirror-events.zh.md | 8 ++++---- .../2026-07-02-remove-stream-chunk-mirror.i18n.yaml | 2 +- .../2026-07-02-remove-stream-chunk-mirror.zh.md | 4 ++-- ...07-04-prune-producerless-vocabulary-variants.i18n.yaml | 2 +- ...026-07-04-prune-producerless-vocabulary-variants.zh.md | 2 +- .../2026-07-04-remove-agent-steering-mirror.i18n.yaml | 2 +- .../2026-07-04-remove-agent-steering-mirror.zh.md | 4 ++-- .../testing/2026-06-19-acp-snapshot-tests.i18n.yaml | 2 +- .../testing/2026-06-19-acp-snapshot-tests.zh.md | 2 +- .../testing/2026-06-19-real-api-e2e-ci.i18n.yaml | 2 +- .../implemented/testing/2026-06-19-real-api-e2e-ci.zh.md | 4 ++-- .../testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml | 2 +- .../testing/2026-06-22-fork-snapshot-scenarios.zh.md | 4 ++-- .../testing/2026-07-04-hook-snapshot-matrix.i18n.yaml | 2 +- .../testing/2026-07-04-hook-snapshot-matrix.zh.md | 8 ++++---- .../2026-07-08-shared-acp-snapshot-package.i18n.yaml | 2 +- .../testing/2026-07-08-shared-acp-snapshot-package.zh.md | 2 +- ...7-07-claude-code-and-codex-subagent-backends.i18n.yaml | 2 +- ...26-07-07-claude-code-and-codex-subagent-backends.zh.md | 6 +++--- .../2026-06-20-drop-durable-step-boundaries.i18n.yaml | 2 +- .../2026-06-20-drop-durable-step-boundaries.zh.md | 2 +- .../2026-06-20-retire-mid-turn-steering.i18n.yaml | 2 +- .../2026-06-20-retire-mid-turn-steering.zh.md | 2 +- .../2026-06-20-truncate-interrupted-turns.i18n.yaml | 2 +- .../2026-06-20-truncate-interrupted-turns.zh.md | 6 +++--- 46 files changed, 66 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index b68aef5fd2..710dee1115 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-event-domain-semantics.md: 7310840088d4ff77ddf83c5c16753b4d46256692 -2026-06-30-event-domain-semantics.zh.md: b082fdfda36968e1946ba6325f2315a970cd445b +2026-06-30-event-domain-semantics.zh.md: 882779fafa5953eab7048d234429a8a030dc695e diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index b082fdfda3..882779fafa 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -26,12 +26,12 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 -**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)从 `session/event` 渲染边界,同时保留其实时目标对象用于固定的 `main` 标签。step 镜像先被移除(它们完全没有消费方);turn 镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 +**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的轮次镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)从 `session/event` 渲染边界,同时保留其实时目标对象用于固定的 `main` 标签。步骤镜像先被移除(它们完全没有消费方);轮次镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 ## 后果 - 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。 -- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的 turn 边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 +- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 - 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 - Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 2aed6c1f15..36b1621ad7 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -3,4 +3,4 @@ # 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: 70d55c62f3d8e43af9deb0f847b1dec7dc474617 +2026-06-30-hook-bridges.zh.md: 3b1fe00ffcd26eaf42af0c089d4659bcef60763d diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 70d55c62f3..3b1fe00ffc 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -27,7 +27,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( | `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 = next-step steering(中途引导)) | 同上 | +| `agent/turn-continuation` | 阻塞的 Stop → `continue`(reason = 下一步 steering(中途引导)) | 同上 | | `subagent/start`(emit) | additionalContext → 注入到存活的进程内 subagent;远程 subagent 无本地注入目标 | 本桥接不支持 | | `subagent/end`(emit) | 仅观察 | 本桥接不支持 | diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 760767565d..8c9946e1e0 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -3,4 +3,4 @@ # 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: 48592b6c4af1bde9daf843692272e556a5d8b206 +2026-06-30-hook-protocol-lib.zh.md: 37bdd1ad0c4f7b8e980b9f81cff787b8896dfc4c diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 48592b6c4a..37bdd1ad0c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -19,7 +19,7 @@ hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Cod - **Execution** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **Decode** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 - **Merge** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block reason 以 `\n\n` 拼接,context/system-messages 按序累积。 -- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,declaration-merge 进 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与 turn 包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 +- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,declaration-merge 进 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 **方言专属(桥接插件):** 构建每个事件的 stdin payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index ba845de777..14d8460137 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-interception-seams.md: af78672c8c6426aa3992355b23b191c3409dcac2 -2026-06-30-interception-seams.zh.md: 61cb0bf5404c72ef7736783ffe2015556375c873 +2026-06-30-interception-seams.zh.md: fe2702c5b74aa4b1822a5506f06557cae5af88a2 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index 61cb0bf540..fe2702c5b7 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -36,7 +36,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 ### 三个承重的循环决策 -1. **在 prompt 策略之前开启轮次。** 被阻止的 prompt 成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。否决记录 `prompt/blocked`(含原始 prompt 和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个 turn 的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。 +1. **在 prompt 策略之前开启轮次。** 被阻止的 prompt 成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。否决记录 `prompt/blocked`(含原始 prompt 和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。 2. **Post-tool `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 显式提供的上下文。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 905cfc1382..7ec0fdd58d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-sandbox.md: 738a1796b047561b861fc237154210659515bd6f -2026-07-06-sandbox.zh.md: ad7d7e83a1a17fffffd33ac09a7cc775c0eb4895 +2026-07-06-sandbox.zh.md: 99ce35885ba5fa5367c4b61aa08357129d972885 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index ad7d7e83a1..99ce35885b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -105,7 +105,7 @@ interface SessionEventMap { 每个拥有者导出相同的三件套:事件声明、纯 fold(`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无共享拥有者服务、无通用 facts map、无注册表:第三个旋钮只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。 -沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'never'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个 pre-step 递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的。 +沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'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 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom`。`session/set_config_option` 通过 permission 服务验证并切换,然后返回完整的刷新状态(规范契约)。 @@ -149,7 +149,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **按会话动态工具 schema**:否决。schema 设计上是注册表全局的(一套 assembly 词汇、固定 header 快照契约),按会话重新注册只能买到执行时严格放宽检查已保证的东西,代价是按会话的 schema 表面和每次切换的 header 变动。 - **将重试硬匹配到先前的拒绝**:否决。命令字符串同一性脆弱(引号、`workdir`、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。 - **通用 `env/state` facts map 加拥有者服务**:否决。approval 和 sandbox 独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 -- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/prompt-submit`),而 pre-step 的位置以一个监听器同时服务合并的轮次入口通知和轮中即时性约束。 +- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/prompt-submit`),而步骤前检查点的位置使一个监听器能够同时服务合并的轮次入口通知和轮中即时性约束。 - **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。 - **用专门的簿记事件追踪「上次告知」**:否决。`request/header` fold 已记录模型看到的确切 prompt;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **ACP session modes 而非 config options**:否决。preset 已经是一个部署定义的 config-option 选择器,且 modes 计划在 ACP v2 中移除。 diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml index 546f331412..429bb90068 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml @@ -3,4 +3,4 @@ # 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: ee5e826b18466b20675746aef17fbd67df8cf37e +2026-07-07-session-prefix.zh.md: 33afa701baf748b177a09995ab4ea4939b50f101 diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md index ee5e826b18..33afa701ba 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md @@ -18,13 +18,13 @@ Status: implemented - **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 Agent Note 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。配套的 [`dsh-agent-loop/invariant`](../../../../packages/core/agent-loop/src/invariant.ts)对每个循环构建的请求重新计算 `messagePrefix + boundary derivation`;启用该贡献时,未记录的前缀无法到达协议格式。 - **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `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 压力会将精确前缀与实际 prompt、工具和已路由模型一起读取;通用 pre-step seam 不携带压缩专属参数。被 cancel/dispose 中断的组合会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。 +- **在持久请求信封中保持精确。** 组合先于实例的首次 `agent/pre-step` 和请求边界。第一个已路由请求会把当前前缀记录在其 header 上,因此步骤后的 token 压力会将精确前缀与实际 prompt、工具和已路由模型一起读取;通用的步骤前检查点 seam 不携带压缩专属参数。被 cancel/dispose 中断的组合会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。 由于组合在边界快照之前运行,组合监听器的会话追加会加入当前请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。 ## 测试 -[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了以下行为:没有变更 header 时的组合一次复用、前置插入顺序、空前缀省略、不可变性、组合先于 pre-step,以及已路由 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) 覆盖了其缓存经济性。 +[拦截测试](../../../../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) 覆盖了其缓存经济性。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index ca85bc0c2f..8253a2ee5b 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-core-data-structures-catalog.md: a2f5e0e7b06e34cb361e4944bbfa3692355a2cbe -2026-06-20-core-data-structures-catalog.zh.md: ee740fd1fb7e5c335bf80e57e27d0a4651be49bd +2026-06-20-core-data-structures-catalog.zh.md: 5bd3b141df786066407264692d1f13e5c0643c01 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index ee740fd1fb..5bd3b141df 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、session/turn/step 生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解“什么是 `Message`、`SessionEvent`、`StreamChunk`”,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 +试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、session/轮次/步骤生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解“什么是 `Message`、`SessionEvent`、`StreamChunk`”,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十个跨包类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note(agent 决策记录)记下了这两项决策。与它配套的[生成式 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml index fc808c5163..5a69a8e3e5 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-03-documentation-graph-atlas.md: 9a30b13f9db6ceb2715517230e349cbe083850ec -2026-07-03-documentation-graph-atlas.zh.md: d4259ea8d977d4966d6c909414bd0dc586a2d145 +2026-07-03-documentation-graph-atlas.zh.md: da0656a7b5b9f54c5c551e01382654966b900579 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md index d4259ea8d9..da0656a7b5 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -40,7 +40,7 @@ Status: implemented | [cordis-agent 应用组合](../../../../examples/cordis-agent/composition.md) | 混合生成式 | `examples/cordis-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | 混合生成式 | `examples/acp-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | | [事件生产者/消费者矩阵](../../../../docs/event-producer-consumer.md) | 混合生成式 | Cordis 事件声明、经 AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 位置,以及显式动态分派覆盖 | -| [agent turn 与 step 生命周期](../../../../docs/agent-lifecycle.md) | 人工维护 | architecture.md 循环生命周期、Cordis 目录链接,以及 session 事件语义 | +| [agent 轮次与步骤生命周期](../../../../docs/agent-lifecycle.md) | 人工维护 | architecture.md 循环生命周期、Cordis 目录链接,以及 session 事件语义 | | [工具执行管线](../../../../docs/tool-execution-pipeline.md) | 人工维护 | 工具管线语义与 `tools/execute` waterfall(瀑布式事件)| | [ACP(Agent Client Protocol)快照回放](../../../../packages/ui/acp/snapshot-replay.md) | 人工策划 | 快照 harness 行为 | diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml index 1240ee061a..8e123cc73d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-collapse-trace-only-session-events.md: fce5c48ef6fcb1abc6e2fbb95dc7e83d22956660 -2026-06-20-collapse-trace-only-session-events.zh.md: 4302646b78ea0b22b479c7068f0f6259bf79edec +2026-06-20-collapse-trace-only-session-events.zh.md: 48a63cdae1217e8244b66603db8b5501239cb9f4 diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md index 4302646b78..48a63cdae1 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -会话事件词汇中包含一些一等事件,它们不属于可回放的对话历史,在生产环境中几乎没有消费方。`usage` 已经作为模型流分片存在,之后循环又追加了一个独立的 `usage` 事件。`error` 与 `turn/end { kind: 'error', message, code }` 中的循环失败原因重复;ACP(Agent Client Protocol)结算读取 turn-end 原因,ACP 渲染忽略 `error` 事件,`deriveMessages()` 也跳过它。 +会话事件词汇中包含一些一等事件,它们不属于可回放的对话历史,在生产环境中几乎没有消费方。`usage` 已经作为模型流分片存在,之后循环又追加了一个独立的 `usage` 事件。`error` 与 `turn/end { kind: 'error', message, code }` 中的循环失败原因重复;ACP(Agent Client Protocol)结算读取轮次结束原因,ACP 渲染忽略 `error` 事件,`deriveMessages()` 也跳过它。 这些事件让规范的 transcript(文本记录)看起来比实际更像遥测数据。它们增加了事件变体、不变式、测试、快照和持久化用例,但作为独立记录并不承载实际功能。它们携带的事实仍然有用:token 用量应当保留以供计费,错误的步骤编号也不应悄然消失。简化的方式是将这些事实折叠进消费方本已必须理解的邻近事件,而非减少记录的信息量。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index 925bfbb43e..a6fd8f47e0 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-public-agent-stop-surface.md: 81a21de30bfbc25688069efbffb21647889b1bdb -2026-06-20-public-agent-stop-surface.zh.md: 4ad4b6b0beb01bc55f10c046023c7851558ecb20 +2026-06-20-public-agent-stop-surface.zh.md: 78fd2e5f0392b2632671311420eca8f5ad96c501 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index 4ad4b6b0be..78fd2e5f03 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -8,15 +8,15 @@ Status: implemented ## 问题 -公共 `Agent` handle 暴露了两种相互重叠的在途工作停止方式:仅针对 step 的 `abort()` 和感知队列的 `cancel()`。前者保留已排队输入,后者则清除已排队和 steering(中途引导)工作,并中止活动 turn。在生产中,ACP(Agent Client Protocol)对 `session/cancel` 使用 `cancel()`,生命周期拥有者则通过 `AgentHandle.dispose()` 拆除 agent(智能体)。没有生产调用方需要一个裸的、仅针对 step 的 abort。 +公共 `Agent` handle 暴露了两种相互重叠的在途工作停止方式:仅针对步骤的 `abort()` 和感知队列的 `cancel()`。前者保留已排队输入,后者则清除已排队和 steering(中途引导)工作,并中止活动轮次。在生产中,ACP(Agent Client Protocol)对 `session/cancel` 使用 `cancel()`,生命周期拥有者则通过 `AgentHandle.dispose()` 拆除 agent(智能体)。没有生产调用方需要一个裸的、仅针对步骤的 abort。 -行为差异确实存在,但已发布代码不需要较窄的操作。AgentLoop 改为为整个 turn 拥有一个私有取消 holder。`cancel(cause?)` 携带类型化的 `user` 或 `parent` 原因,默认为 `user`,并丢弃待处理输入;释放仍是单独的生命周期中断。完整的归属与传播契约位于[显式 turn 取消 Agent Note(agent 决策记录)](../architecture/2026-07-16-explicit-turn-cancellation.md)。 +行为差异确实存在,但已发布代码不需要较窄的操作。AgentLoop 改为为整个轮次拥有一个私有取消 holder。`cancel(cause?)` 携带类型化的 `user` 或 `parent` 原因,默认为 `user`,并丢弃待处理输入;释放仍是单独的生命周期中断。完整的归属与传播契约位于[显式轮次取消 Agent Note(agent 决策记录)](../architecture/2026-07-16-explicit-turn-cancellation.md)。 多余的公开接口使得循环不得不承载一个本质上属于内部拆卸的公开动词:`abort()` 必须被文档描述为有别于队列感知的取消,尽管 UI 取消几乎总是需要更广泛的操作。 ## 决策 -`cancel()` 是 `Agent` 上唯一的公共*停止*原语。生命周期拥有者使用 `AgentHandle.dispose()` 停止并注销 agent;非拥有者使用 `cancel()` 放弃当前和已排队工作。实现保留一个私有 turn 取消 holder,但它不属于面向插件的 `Agent` 契约。 +`cancel()` 是 `Agent` 上唯一的公共*停止*原语。生命周期拥有者使用 `AgentHandle.dispose()` 停止并注销 agent;非拥有者使用 `cancel()` 放弃当前和已排队工作。实现保留一个私有轮次取消 holder,但它不属于面向插件的 `Agent` 契约。 `whenIdle()` **保留**为公开的静默观测原语(agent 从 `running` 状态稳定后 resolve,已处于 idle 时立即 resolve,dispose 后等待循环退出)。它不是停止动词;它是非所有者在不 dispose agent 的前提下观测停止*完成*的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 销毁它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 @@ -36,4 +36,4 @@ Status: implemented ## 相关 -本 Agent Note 只移除冗余的停止动词。turn 中途 steering 仍是一条有意保留的消息路径;静止观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 和 identity。 +本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;静止观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 和 identity。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 64c80eedcc..8843964ff6 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-agent-boundary-mirror-events.md: 8c5bb74f2347fe0269cbb9c6504137de761ab919 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: feed8239b6a07c2e27866f72954cfb95830541f4 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 3d130a68eccaaf2996cd80bc003f6b7fb16efa51 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index feed8239b6..3d130a68ec 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -15,7 +15,7 @@ Status: implemented ## 问题 -循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费者在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择 session log,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染 turn 边界的生产消费者;它已经从 `session/event` 渲染工具调用和结果。 +循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费者在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择 session log,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费者;它已经从 `session/event` 渲染工具调用和结果。 这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 @@ -25,7 +25,7 @@ Status: implemented 四个持久边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——已从 agent(智能体)事件分类中移除。希望在边界处取得 agent handle 的 UI 会保留来自 `agent/created`/`agent/disposed` 的实时目标对象,并直接比较其 session;`dsh-ui-stdio` 据此为应用拥有的 agent 标记 `[main turn N]` 头部,其他 session 则渲染其持久 id。规范记录仍是事件溯源 session log。 -step 镜像(完全没有消费者)最先在[事件域语义 Agent Note(agent 决策记录)](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在 turn 边界取得 `Agent` handle 为由,保留了 turn 镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此“ui-stdio 需要它”并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 +步骤镜像(完全没有消费者)最先在[事件域语义 Agent Note(agent 决策记录)](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在轮次边界取得 `Agent` handle 为由,保留了轮次镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此“ui-stdio 需要它”并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 ## 范围:移除什么、不移除什么 @@ -40,8 +40,8 @@ step 镜像(完全没有消费者)最先在[事件域语义 Agent Note(age ## 曾考虑的替代方案 - **将 `agent/steering` 一并移除**——原始提案的形状;作为范围蔓延被排除:它镜像持久的 `steering/message` 控制记录,而非边界,后来由[自己的决策](2026-07-04-remove-agent-steering-mirror.md)移除(`agent/stream-chunk` 也由 [stream chunk 镜像 Agent Note](2026-07-02-remove-stream-chunk-mirror.md) 移除)。 -- **为 stdio UI 保留 turn 镜像**——[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的原始立场;在此否决,因为 `dsh-ui-stdio` 是可随时丢弃的测试 REPL,而非承载关键约束的消费者,并且它改为根据 `session/event` 加自己的实时目标对象渲染边界。 +- **为 stdio UI 保留轮次镜像**——[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的原始立场;在此否决,因为 `dsh-ui-stdio` 是可随时丢弃的测试 REPL,而非承载关键约束的消费者,并且它改为根据 `session/event` 加自己的实时目标对象渲染边界。 ## 后果 -插件不能再从便捷的 `Agent` 优先事件观察 turn/step 边界。它需要订阅 `session/event`;如果需要实时对象,则通过 `ctx.agents` 解析共享 id,或保留自己已经拥有的对象。这是可以接受的取舍:边界消费者不应依赖可能与持久日志发生漂移的第二条事件 feed。 +插件不能再从便捷的 `Agent` 优先事件观察轮次/步骤边界。它需要订阅 `session/event`;如果需要实时对象,则通过 `ctx.agents` 解析共享 id,或保留自己已经拥有的对象。这是可以接受的取舍:边界消费者不应依赖可能与持久日志发生漂移的第二条事件 feed。 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 6b26f18d77..761fd95e87 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-remove-stream-chunk-mirror.md: 5dd816940a4b2c2b63980e01f1bd4e0aac53a3e2 -2026-07-02-remove-stream-chunk-mirror.zh.md: d4bfe540ecb3f938721f2b387f302caf163b0f94 +2026-07-02-remove-stream-chunk-mirror.zh.md: a474238f19d58e261afba30227e5b1eaa25fcf7a diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index d4bfe540ec..a474238f19 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -19,13 +19,13 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror 实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方直接丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 -这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为 turn/step 边界消除的重复相同:消费者面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把 chunk 流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将 chunk 流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 +这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复相同:消费者面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把 chunk 流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将 chunk 流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 推迟所依赖的前提已经明确:chunk 持久化是权威的,且将保留。停止持久化 chunk、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 ## 决策 -从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化与回放已经使用的正是同一个序列。`session/event` 是唯一的实时 transcript(文本记录)流(assistant chunk、turn/step 边界、工具活动、todo)。 +从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化与回放已经使用的正是同一个序列。`session/event` 是唯一的实时 transcript(文本记录)流(assistant chunk、轮次/步骤边界、工具活动、todo)。 **消费方。** 唯一重要的生产消费方——ACP(Agent Client Protocol)桥接(`dsh-acp`,面向编辑器的真实流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其 chunk 渲染被折叠进该监听器作为 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立监听器(`agent/stream-chunk` 和 `session/event`)之间共享,chunk 与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml index 1c00f9b991..cd22871392 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-producerless-vocabulary-variants.md: 34492e6906cd2d795f880310b1bcd120e3953fcf -2026-07-04-prune-producerless-vocabulary-variants.zh.md: 710f64d7e848bcabb8a1bc0c0c8be9af7b2ca1b0 +2026-07-04-prune-producerless-vocabulary-variants.zh.md: c564b3052719cc7e9aef60775a1801f3e214d4cc diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md index 710f64d7e8..c564b30527 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -`CacheHint`、其 `cache?` 块字段、`agent` 消息来源变体和 `continuation` turn 触发器变体均已删除:已发布词汇不再携带它们。llm-replay fixture 使用 `injection` 触发器(任何非 `message` 触发器都能满足其用途)。[core.md](../../../../docs/core-data-structures/core.md) 和 [session.md](../../../../docs/core-data-structures/session.md) 中的 type-equiv 粘贴与裁剪后的 map 匹配——两个符号仍保留在 `scripts/type-equiv.manifest.json` 中的行,因为每个 map 都只是少了一个成员而继续存在——并且[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)的后果按照 [implemented/AGENTS.md](../AGENTS.md),将 cache hint 记录为由生产者门控,而不是已有归属。 +`CacheHint`、其 `cache?` 块字段、`agent` 消息来源变体和 `continuation` 轮次触发器变体均已删除:已发布词汇不再携带它们。llm-replay fixture 使用 `injection` 触发器(任何非 `message` 触发器都能满足其用途)。[core.md](../../../../docs/core-data-structures/core.md) 和 [session.md](../../../../docs/core-data-structures/session.md) 中的 type-equiv 粘贴与裁剪后的 map 匹配——两个符号仍保留在 `scripts/type-equiv.manifest.json` 中的行,因为每个 map 都只是少了一个成员而继续存在——并且[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)的后果按照 [implemented/AGENTS.md](../AGENTS.md),将 cache hint 记录为由生产者门控,而不是已有归属。 每个变体在获得真正的生产者之日回归,这正是映射表设计的增长方式:缓存功能连同传输它的适配器一起重新添加 `cache`;subagent 归属连同打标的后端和路由它的消费方一起重新添加 `agent`;真正启动新轮次的自动续行功能连同发出它的插件一起重新添加 `continuation`。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml index c676902cfc..7735b3f6d7 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-remove-agent-steering-mirror.md: 9f7cd5abe968ff216cbd7012163ea1c04dc00599 -2026-07-04-remove-agent-steering-mirror.zh.md: b498f473941b69eb5eab66e2e3034976da75e62c +2026-07-04-remove-agent-steering-mirror.zh.md: 2650dbd142288458afc429fd87c564fed01e1493 diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md index b498f47394..2650dbd142 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md @@ -10,7 +10,7 @@ Status: implemented `agent/steering` 以相同的 payload 重复了紧接其前的持久事件 `steering/message`。`agent/queued` 仍保留为纯瞬态信号,因为它在持久化之前触发,覆盖了可能在进入日志前被取消的工作。 -Steering 承载真实生产流量——hook bridge 的 turn 延续决策通过 `inbox.steer()` 注入其理由,最终成为由 hook 矩阵预期输出固定的持久 `steering/message` 事件——而这些消费者无一例外都观察持久事件。没有任何内容观察镜像。 +Steering 承载真实生产流量——hook bridge 的轮次延续决策通过 `inbox.steer()` 注入其理由,最终成为由 hook 矩阵预期输出固定的持久 `steering/message` 事件——而这些消费者无一例外都观察持久事件。没有任何内容观察镜像。 ## 决策 @@ -22,7 +22,7 @@ Steering 承载真实生产流量——hook bridge 的 turn 延续决策通过 ` ### 为什么不保留? -“它是控制信号,不是边界”——但该分类的实际区分是镜像/仅实时,而非控制/边界,并且该事件确实是镜像。希望在入队时收到通知的消费者可以使用 `agent/queued`(及其 steering 标记);希望在排空时收到通知的消费者,本质上是在要求获知 `steering/message` 被追加的时刻,而 `session/event` 会交付相同 payload 并附带持久性。遭拒绝的[退役 turn 中途 steering Agent Note](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md)所捍卫的是 steering *功能*——`steer()`、持久事件、强制延续——本次移除不会触及其中任何一项。 +“它是控制信号,不是边界”——但该分类的实际区分是镜像/仅实时,而非控制/边界,并且该事件确实是镜像。希望在入队时收到通知的消费者可以使用 `agent/queued`(及其 steering 标记);希望在排空时收到通知的消费者,本质上是在要求获知 `steering/message` 被追加的时刻,而 `session/event` 会交付相同 payload 并附带持久性。遭拒绝的[退役轮次中途 steering Agent Note](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md)所捍卫的是 steering *功能*——`steer()`、持久事件、强制延续——本次移除不会触及其中任何一项。 ## 验证 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index c068706aeb..2e30b8e040 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-acp-snapshot-tests.md: 43900632c4e5e3904c2d0e3b75f2a3fe3b3a50cc -2026-06-19-acp-snapshot-tests.zh.md: 9d9e49cf799ccc07fb9fbc4b13261b7d305d68dc +2026-06-19-acp-snapshot-tests.zh.md: 58e9c10bfe5d02179eb31e510d63691b99a0c1da diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 9d9e49cf79..58e9c10bfe 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -24,7 +24,7 @@ Status: implemented ### 回放从日志推导模型脚本 -`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。agent loop(智能体循环)每个 step 发起一次流调用,因此分组精确对应,错误结束 chunk 也无需特殊处理。 +`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束 chunk 也无需特殊处理。 ### 内存中的回放条目遵守完整的 LLM 契约 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index 4a14127a76..abc6579956 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-real-api-e2e-ci.md: a9289980ada8ab6390b5daa488f07ec258db1bd5 -2026-06-19-real-api-e2e-ci.zh.md: 2e8f9fff5cdbada2b1ebfd45c4b3ebbc10630f09 +2026-06-19-real-api-e2e-ci.zh.md: bb3dd888dbdf8f725953d300c96d875cadae8673 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 2e8f9fff5c..bb3dd888db 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACP(Agent Client Protocol)inject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实编辑器 session 却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多 turn、恢复、ACP-over-stdio。 +根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACP(Agent Client Protocol)inject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实编辑器 session 却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多轮次、恢复、ACP-over-stdio。 默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。 @@ -49,7 +49,7 @@ Dependabot 子句基于 PR **作者**(`pull_request.user.login`)而非 `gith repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试读取的 `DEEPSEEK_API_KEY` 环境变量(`process.env.DEEPSEEK_API_KEY`)。独立的 secret 名称记录了意图(这是*外部*公开 API 密钥,不是内部端点密钥),并允许内部端点密钥日后无冲突地共存。以下卫生选择均为防御性设计: -- **Step 级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 +- **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 - **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。 - **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(`vitest.e2e.config.ts` 存在时会加载)无法静默地将运行重定向到其他端点。 - **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml index fd59b8dff8..e6fb378eb3 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-fork-snapshot-scenarios.md: 46c688a4095a1d8af32b3b99887929f71a1526ce -2026-06-22-fork-snapshot-scenarios.zh.md: afeb495e480a601656fc551a478e0e40c579cfd5 +2026-06-22-fork-snapshot-scenarios.zh.md: 4fe184685d4cfaa6f096af33d782ce3a1391b877 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md index afeb495e48..4fe184685d 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md @@ -15,11 +15,11 @@ Status: implemented 针对真实 API 记录两个场景,均在默认门禁中以无密钥方式回放: - **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此可以从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的记录而非手工合成。 -- **`subagent-mixed`**——父项完成一个 turn,随后在同一 transcript 中通过 `subagent` 委托一次(全新 spawn 子项,`seedLength` 为 0),再通过 `subagent_fork` 委托一次(fork 子项,`seedLength` 非零)。这是 seed 边界与逐 session 重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(`seedLength` 为 0 = 无操作,`seedLength > 0` = 裁剪继承前缀),两个子项按 `createdAt` 排列为先 spawn、后 fork。 +- **`subagent-mixed`**——父项完成一个轮次,随后在同一 transcript 中通过 `subagent` 委托一次(全新 spawn 子项,`seedLength` 为 0),再通过 `subagent_fork` 委托一次(fork 子项,`seedLength` 非零)。这是 seed 边界与逐 session 重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(`seedLength` 为 0 = 无操作,`seedLength > 0` = 裁剪继承前缀),两个子项按 `createdAt` 排列为先 spawn、后 fork。 ### 为什么需要一个已完成的第一轮次 -fork 后端使用父项的**已配平完整 turn 前缀**为子项提供 seed。父项若在第一个 turn 就执行 fork,没有已完成 turn 可供继承,因此 seed 为空(≡ 全新 spawn,`seedLength` 为 0)——这不会覆盖 slice。因此,两个场景都使用双 prompt 输入:第一个 prompt 完成一个 turn(建立稍后要求子项回忆的 codeword),第二个 prompt 委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 `seedLength`。 +fork 后端使用父项的**已配平完整轮次前缀**为子项提供 seed。父项若在第一个轮次就执行 fork,没有已完成轮次可供继承,因此 seed 为空(≡ 全新 spawn,`seedLength` 为 0)——这不会覆盖 slice。因此,两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立稍后要求子项回忆的 codeword),第二个 prompt 委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 `seedLength`。 ## 后果 diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml index fd07ac1419..2b114cbf32 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-hook-snapshot-matrix.md: ceb91a70e1f9582cf2a7cb4cec0ba8aaf5699b8e -2026-07-04-hook-snapshot-matrix.zh.md: 0e2318d48dbd0200c04dd35decf8046ed6ecce9f +2026-07-04-hook-snapshot-matrix.zh.md: 649d2dd4b3149a9c78ff47cdaf16d7d38fc24fe3 diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md index 0e2318d48d..649d2dd4b3 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md @@ -37,15 +37,15 @@ hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) 在构建矩阵过程中发现,记录于此是因为这些遗漏是决策而非疏忽: -- **`SessionStart` 和 `SubagentStart`** 通过脱离且尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有 turn 绑定。由此产生的 `context/message` 会与它应先于的工作(首次模型请求 / 子项的第一个 turn)竞速,并落在不确定的日志位置。记录的预期输出甚至无法在自己的重放中复现——对两者执行 10 次重放稳定性检查,结果均为 10/10 次失败。它们继续留在 bridge 的单元覆盖率中,那里会直接驱动接缝而不存在时序竞速。(如果注入未来改为绑定 turn 且具备确定性——`TODO(session-start-gating)` 所指方向——它们就能接受快照测试。) -- **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递 turn(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无 hook 运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。 +- **`SessionStart` 和 `SubagentStart`** 通过脱离且尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有轮次绑定。由此产生的 `context/message` 会与它应先于的工作(首次模型请求 / 子项的第一个轮次)竞速,并落在不确定的日志位置。记录的预期输出甚至无法在自己的重放中复现——对两者执行 10 次重放稳定性检查,结果均为 10/10 次失败。它们继续留在 bridge 的单元覆盖率中,那里会直接驱动接缝而不存在时序竞速。(如果注入未来改为绑定轮次且具备确定性——`TODO(session-start-gating)` 所指方向——它们就能接受快照测试。) +- **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递轮次(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无 hook 运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。 因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的 hook 点,涵盖两种方言。 ## 后果 -- 现在,两种 dialect 中每个具有可观察 transcript 的 bridge 接缝映射,都在真实应用的完整 transcript 层受到守护——包括此前完全没有端到端覆盖的 Codex bridge。记录的预期输出捕获模型对遭拒绝/遭阻止/强制继续 turn 的真实反应,而手工编写的 transcript 只能猜测这种反应。 -- `UserPromptSubmit` 阻止场景无需密钥即可编写(没有模型 turn);其余场景从已记录 fixture(测试前置数据)无需密钥重放。`pnpm run test:snapshot:record` 从实时 API 重新生成记录式 fixture,并像所有记录场景一样在缺少密钥时自行跳过。 +- 现在,两种 dialect 中每个具有可观察 transcript 的 bridge 接缝映射,都在真实应用的完整 transcript 层受到守护——包括此前完全没有端到端覆盖的 Codex bridge。记录的预期输出捕获模型对遭拒绝/遭阻止/强制继续轮次的真实反应,而手工编写的 transcript 只能猜测这种反应。 +- `UserPromptSubmit` 阻止场景无需密钥即可编写(没有模型轮次);其余场景从已记录 fixture(测试前置数据)无需密钥重放。`pnpm run test:snapshot:record` 从实时 API 重新生成记录式 fixture,并像所有记录场景一样在缺少密钥时自行跳过。 - 证明会变红的准则仍成立:篡改 hook 配置输出(例如改变拒绝理由)会让相应场景在重放时变红——hook 进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际 hook→接缝→循环路径,而非其 mock。 - `acp-agent` 演示现在加载了一个通常会无操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。 diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml index 7eaf1b2cc5..588d335eee 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-shared-acp-snapshot-package.md: 3e5a2b12114d535490a17361128862f6d1c09a73 -2026-07-08-shared-acp-snapshot-package.zh.md: 63daf0bd8b18a5afb44161b1621ab16ff7285ba2 +2026-07-08-shared-acp-snapshot-package.zh.md: 86b3e65c76a1bde593c30d0d1c20010ec7e4d6d5 diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md index 63daf0bd8b..86b3e65c76 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -33,7 +33,7 @@ ACP(Agent Client Protocol)快照层([快照 Agent Note(agent 决策记 ## 测试 -提取一致性得到机械证明:迁移后,`pnpm run test:snapshot` 的结果与基准提交匹配,`examples/acp-agent/tests/snapshots/` 下没有任何字节变化。包的 `src/` 在门禁单元运行中保持逐文件 100% 语句/分支/函数/行覆盖,并通过脚本化 fake ACP bin(`tests/fixtures/fake-acp-agent.ts`,每个场景由 fixture 旁的 `behavior.json` 编排行为)经过真实 launcher 驱动:`harness.spec.ts` 直接覆盖 launcher 默认值、捕获、更新等待、关闭以及环境/配置变体,随后覆盖每种场景 step 操作、两个 expect-error 分支、权限队列(选择、后备、不可能点击)、workspace seed,以及采集顺序/噪音/后备分支;`suite.spec.ts` 在收集时真实运行 factory——一个针对已提交合成 fixture 的重放套件和一个针对临时副本的记录套件(写回从不触及已提交树;`ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` 会重新引导它)——并包含纯辅助函数的直接用例。fake bin 会把 `session/new` cwd 而非 `process.cwd()` 代入脚本化日志,与真实 bin 请求头携带的内容一致(darwin 会将 `/var/folders/…` realpath 为 `/private/var/folders/…`)。 +提取一致性得到机械证明:迁移后,`pnpm run test:snapshot` 的结果与基准提交匹配,`examples/acp-agent/tests/snapshots/` 下没有任何字节变化。包的 `src/` 在门禁单元运行中保持逐文件 100% 语句/分支/函数/行覆盖,并通过脚本化 fake ACP bin(`tests/fixtures/fake-acp-agent.ts`,每个场景由 fixture 旁的 `behavior.json` 编排行为)经过真实 launcher 驱动:`harness.spec.ts` 直接覆盖 launcher 默认值、捕获、更新等待、关闭以及环境/配置变体,随后覆盖每种场景步骤操作、两个 expect-error 分支、权限队列(选择、后备、不可能点击)、workspace seed,以及采集顺序/噪音/后备分支;`suite.spec.ts` 在收集时真实运行 factory——一个针对已提交合成 fixture 的重放套件和一个针对临时副本的记录套件(写回从不触及已提交树;`ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` 会重新引导它)——并包含纯辅助函数的直接用例。fake bin 会把 `session/new` cwd 而非 `process.cwd()` 代入脚本化日志,与真实 bin 请求头携带的内容一致(darwin 会将 `/var/folders/…` realpath 为 `/private/var/folders/…`)。 ## 后果 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index 5cabc005db..8bb7750ed7 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 893d6a3cbac5293d6fdf2d6fa4d7ad00f856c61d +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 46bbc0023b39899bebfd6ecfe259625aeae11c26 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 893d6a3cba..46bbc0023b 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -26,9 +26,9 @@ subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent- **codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 -- 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的 turn;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 +- 生命周期:`initialize{clientInfo}` + `initialized` → `thread/start`(接受 `cwd`、`model`、`sandbox`、`approvalPolicy`、`ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的轮次;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}` 的 `turn/completed` 通知。 - 审批是服务端发起的请求——`item/commandExecution/requestApproval`、`item/fileChange/requestApproval`、`item/permissions/requestApproval`、`item/tool/requestUserInput`、`mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。 -- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时大声结算为 `error`,而非等待 turn。 +- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC,`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时大声结算为 `error`,而非等待轮次。 - 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。 ## 隔离与凭证 @@ -43,7 +43,7 @@ subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent- Claude Code:`success` → `completed`;`error_max_turns`、`error_during_execution`、`error_max_budget_usd`、`error_max_structured_output_retries` → `error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不是成功);生成器中止 → `aborted`;未知值 → `error`。Codex:`Turn.status` 为 `completed` → `completed`;`interrupted` → `aborted`;`failed` 且 `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`,其他 `failed` → `error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。 -活性姿态,明确声明:teardown 时序是配置项,turn 时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意不设 turn 时长或启动超时——与 ACP 一致:turn 期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent turn 合理地可达数分钟,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。 +活性姿态,明确声明:teardown 时序是配置项,轮次时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意不设轮次时长或启动超时——与 ACP 一致:轮次期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent 轮次持续数分钟也属合理,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。 ## 测试 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml index b5462fbea8..e9a21021b1 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-durable-step-boundaries.md: c5c4f269a378e334c4dc509d1288146d77d9a520 -2026-06-20-drop-durable-step-boundaries.zh.md: e98f5e479cdef43bf134e3e2d240ce0f3be7ce0e +2026-06-20-drop-durable-step-boundaries.zh.md: 17d5b6d1860fd256758cadb429de0f390089826e diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md index e98f5e479c..17d5b6d186 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除持久化的步骤边界事件 -Status: rejected — `step/end` 是 model step 已完成的持久信号;保留对称的 `step/start` / `step/end` 对,比从相邻的 step-scoped 事件推断完成状态更便于理解崩溃修复、不变式与 transcript 检查。 +Status: rejected — `step/end` 是模型步骤已完成的持久信号;保留对称的 `step/start` / `step/end` 对,比从相邻的步骤作用域事件推断完成状态更便于理解崩溃修复、不变式与 transcript 检查。 [English](2026-06-20-drop-durable-step-boundaries.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml index e591923f6e..dc925b0bbc 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-retire-mid-turn-steering.md: a8812b3222739244d77f4d4dab60cf7c0cd6907d -2026-06-20-retire-mid-turn-steering.zh.md: c4de65bc763344ed4a060244515a403b695d7fd9 +2026-06-20-retire-mid-turn-steering.zh.md: f31a062e73043348183c27053e73f79168ba8cef diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md index c4de65bc76..f31a062e73 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除轮次中途引导 -Status: rejected — mid-turn steering 是一项有意设计的 agent 能力,用于接收 between-step 的用户/插件输入以及未来的 goal/loop 工作流。它是面向产品方向的复杂度,而非 `send()` 的意外重复。 +Status: rejected — 轮次中途 steering 是一项有意设计的 agent 能力,用于接收步骤之间的用户/插件输入以及未来的 goal/loop 工作流。它是面向产品方向的复杂度,而非 `send()` 的意外重复。 [English](2026-06-20-retire-mid-turn-steering.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml index 1861fcb961..98845d7e03 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-truncate-interrupted-turns.md: af18618ad4c41af125e37c51b9fd971dd8eae64e -2026-06-20-truncate-interrupted-turns.zh.md: a6c7d1df32d444d301635665afb4fe3b0c4ac5af +2026-06-20-truncate-interrupted-turns.zh.md: a20d0169f7735aa7a9437c10c958580c00704171 diff --git a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md index a6c7d1df32..a20d0169f7 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md @@ -1,12 +1,12 @@ # Agent Note: 加载时截断被中断的最终轮次 -Status: rejected — 单个 turn 可以包含大量真实工作,包括多个 steps 和大量工具输出。保留被中断的 turns,优于在加载时静默丢弃这段尾部。 +Status: rejected — 单个轮次可以包含大量真实工作,包括多个步骤和大量工具输出。保留被中断的轮次,优于在加载时静默丢弃这段尾部。 [English](2026-06-20-truncate-interrupted-turns.md) | 中文 ## 问题 -当前的持久化契约会保留已持久写入但从未关闭的最终轮次。加载时,`interruptedTurnClosers()` 扫描尾部,为未应答的工具调用合成 error `tool/result` 事件,在 step 处于打开状态时追加 `step/end`,追加 `turn/end { kind: 'interrupted' }`,并要求后端持久提交这次修复。协调器、JSONL 后端、SQLite 后端、会话事件词汇、不变式、文档和测试都对这条合成关闭路径进行了建模。 +当前的持久化契约会保留已持久写入但从未关闭的最终轮次。加载时,`interruptedTurnClosers()` 扫描尾部,为未应答的工具调用合成 error `tool/result` 事件,在步骤处于打开状态时追加 `step/end`,追加 `turn/end { kind: 'interrupted' }`,并要求后端持久提交这次修复。协调器、JSONL 后端、SQLite 后端、会话事件词汇、不变式、文档和测试都对这条合成关闭路径进行了建模。 这是一套庞大的机制,只为保留上次崩溃轮次中的部分工作。它还会凭空创造从未发生过的事件。合成的工具结果虽然有用(因为它使提供方历史保持合法),但也意味着恢复后的日志中包含了模型可见、却并非任何工具产出的文本。当前设计在尚无已发布产品、也没有真实恢复 UX 来证明部分轮次恢复确有价值的情况下,就优化了最大化尾部保留。 @@ -31,6 +31,6 @@ Status: rejected — 单个 turn 可以包含大量真实工作,包括多个 s ## 相关 -本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化 step 边界事件的大部分动机,使[移除持久化 step 边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 +本提案是对[会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)与[轮次封闭不变式](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md)的直接简化。它还移除了持久化步骤边界事件的大部分动机,使[移除持久化步骤边界事件](2026-06-20-drop-durable-step-boundaries.md)的改动更小。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> From eaf4f2c06297b6cbdf05375a38a5f363155b76e7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:02:07 +0800 Subject: [PATCH 029/113] docs(i18n): allow zero-step turns in glossary --- docs/glossary.i18n.yaml | 4 ++-- docs/glossary.md | 2 +- docs/glossary.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 9724877d71..03225c38dd 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -glossary.md: c1931c0e0c630d05f5bd4fc3f30720f858f1175e -glossary.zh.md: 951abebc162e2456211437fcd5b27dd57783e9b6 +glossary.md: 414fb5342886ce9fc775c529a7e61f0d760e93c6 +glossary.zh.md: 0a307b08dad97613e64b43a8857e9f24339f39da diff --git a/docs/glossary.md b/docs/glossary.md index c1931c0e0c..414fb53428 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,7 +33,7 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i ## loop hierarchy - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. <a id="turn"></a> -- **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. <a id="step"></a> +- **step** — one model request plus the tool executions caused by its response; a turn contains zero or more steps. <a id="step"></a> - **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. <a id="round"></a> ## Ralph diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index 951abebc16..0a307b08da 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -33,7 +33,7 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S ## 循环层级 - **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a> -- **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含一个或多个步骤。<a id="step"></a> +- **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。<a id="step"></a> - **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。<a id="round"></a> ## Ralph From 8a7112af45ed2878c89d7e3fec82fe9c32ffbb2d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:06:47 +0800 Subject: [PATCH 030/113] docs(i18n): translate turn and step in note comment --- .../2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml | 2 +- .../2026-06-20-remove-agent-boundary-mirror-events.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 8843964ff6..0dc354fa47 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-agent-boundary-mirror-events.md: 8c5bb74f2347fe0269cbb9c6504137de761ab919 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: 3d130a68eccaaf2996cd80bc003f6b7fb16efa51 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 37d9d1798bb00d66c0dfd5211ed1932522ce228a diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index 3d130a68ec..37d9d1798b 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -5,7 +5,7 @@ Status: implemented [English](2026-06-20-remove-agent-boundary-mirror-events.md) | 中文 <!-- 以修订、收窄后的形式落地: - 移除了四个 turn/step 边界镜像;此处保留了 `agent/steering` 和 + 移除了四个轮次/步骤边界镜像;此处保留了 `agent/steering` 和 `agent/stream-chunk`(它们不是持久边界镜像——参见 “范围:移除什么、不移除什么”)。原始提案将 `agent/steering` 与其他项一并 移除;把它排除在外,使本 Agent Note 的范围保持在边界上。后来每个保留事件 From 22325d3d51c3ad43596525b83cf7dd5a7d85fd48 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:17:19 +0800 Subject: [PATCH 031/113] docs(site): publish Chinese Cordis primer --- scripts/project-doc-site.spec.ts | 4 ++-- website/docs.ts | 23 +++++++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 7d1c3c1550..b9872432a5 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -172,13 +172,13 @@ describe('rewriteMarkdown', () => { }) describe('docsPages locale routes', () => { - it('publishes every route in both locales and selects paired user sources', () => { + it('publishes every route in both locales and selects paired sources', () => { const byRoute = new Map(docsPages.map(page => [page.route, page])) for (const page of docsPages.filter(page => page.locale === 'root')) { const counterpart = byRoute.get(`en/${page.route}`) expect(counterpart, page.route).toBeDefined() expect(counterpart?.locale).toBe('en') - if (page.source.startsWith('docs/user/')) { + if (page.source.startsWith('docs/user/') || page.route === 'reference/cordis-primer.md') { expect(page.source).toMatch(/\.zh\.md$/) expect(page.contentLocale).toBe('zh-CN') expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md')) diff --git a/website/docs.ts b/website/docs.ts index ee7ad64061..e033e2d6ef 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -226,14 +226,24 @@ const cordisTutorial = mirroredPages(([ ...(file === 'index.md' ? { sourceAliases: ['docs/cordis-tutorial'] } : {}), }))) +const cordisPrimerReference = pairedPages([ + { + source: 'docs/cordis-primer.md', + route: 'reference/cordis-primer.md', + label: { root: 'Cordis 入门', en: 'Cordis primer' }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '概念', en: 'Concepts' }, + order: 1, + }, +]) + const reference = mirroredPages([ ...([ - ['docs/architecture.md', 'reference/index.md', '架构', 'Architecture'], - ['docs/cordis-primer.md', 'reference/cordis-primer.md', 'Cordis 入门', 'Cordis primer'], - ['docs/capability-seams.md', 'reference/capability-seams.md', '能力服务', 'Capability services'], - ['docs/agent-lifecycle.md', 'reference/agent-lifecycle.md', 'Agent 生命周期', 'Agent lifecycle'], - ['docs/tool-execution-pipeline.md', 'reference/tool-execution-pipeline.md', 'Tool 执行', 'Tool execution'], - ] as const).map(([source, route, rootLabel, enLabel], order): MirroredPage => ({ + ['docs/architecture.md', 'reference/index.md', '架构', 'Architecture', 0], + ['docs/capability-seams.md', 'reference/capability-seams.md', '能力服务', 'Capability services', 2], + ['docs/agent-lifecycle.md', 'reference/agent-lifecycle.md', 'Agent 生命周期', 'Agent lifecycle', 3], + ['docs/tool-execution-pipeline.md', 'reference/tool-execution-pipeline.md', 'Tool 执行', 'Tool execution', 4], + ] as const).map(([source, route, rootLabel, enLabel, order]): MirroredPage => ({ source, route, contentLocale: 'en-US', @@ -325,5 +335,6 @@ export const docsPages: DocsPage[] = [ ...homeAndGuide, ...develop, ...cordisTutorial, + ...cordisPrimerReference, ...reference, ] From cecd0fedf54cc560c150b2116d320cefc4ebcfbb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:48:50 +0800 Subject: [PATCH 032/113] docs(i18n): normalize seam consumer terminology --- ...-07-05-subagent-provider-lifecycle-events.i18n.yaml | 2 +- ...2026-07-05-subagent-provider-lifecycle-events.zh.md | 2 +- .../2026-07-12-agent-scope-runtime-design.i18n.yaml | 2 +- .../2026-07-12-agent-scope-runtime-design.zh.md | 2 +- .../2026-06-20-core-data-structures-catalog.i18n.yaml | 2 +- .../2026-06-20-core-data-structures-catalog.zh.md | 2 +- .../process/2026-07-02-tool-schema-catalog.i18n.yaml | 2 +- .../process/2026-07-02-tool-schema-catalog.zh.md | 2 +- .../2026-07-03-documentation-graph-atlas.i18n.yaml | 2 +- .../process/2026-07-03-documentation-graph-atlas.zh.md | 10 +++++----- .../2026-07-06-parallel-github-ci-gates.i18n.yaml | 2 +- .../process/2026-07-06-parallel-github-ci-gates.zh.md | 6 +++--- .../2026-07-10-readme-known-limitations-gate.i18n.yaml | 2 +- .../2026-07-10-readme-known-limitations-gate.zh.md | 2 +- ...6-07-12-package-model-experience-contract.i18n.yaml | 2 +- .../2026-07-12-package-model-experience-contract.zh.md | 4 ++-- ...-drop-unconsumed-llm-adapter-change-event.i18n.yaml | 2 +- ...6-20-drop-unconsumed-llm-adapter-change-event.zh.md | 4 ++-- ...20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml | 2 +- ...-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md | 2 +- .../2026-06-20-prune-dead-seam-methods.i18n.yaml | 2 +- .../2026-06-20-prune-dead-seam-methods.zh.md | 8 ++++---- .../2026-06-20-public-agent-stop-surface.i18n.yaml | 2 +- .../2026-06-20-public-agent-stop-surface.zh.md | 2 +- ...06-20-remove-agent-boundary-mirror-events.i18n.yaml | 2 +- ...026-06-20-remove-agent-boundary-mirror-events.zh.md | 8 ++++---- .../2026-06-20-unify-agent-and-session-id.i18n.yaml | 2 +- .../2026-06-20-unify-agent-and-session-id.zh.md | 4 ++-- .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 2 +- .../2026-06-26-fsspec-style-fs-seam.zh.md | 6 +++--- .../2026-07-02-remove-stream-chunk-mirror.i18n.yaml | 2 +- .../2026-07-02-remove-stream-chunk-mirror.zh.md | 4 ++-- ...4-drop-unconsumed-web-observation-surface.i18n.yaml | 2 +- ...07-04-drop-unconsumed-web-observation-surface.zh.md | 6 +++--- .../2026-07-04-fold-stdio-ui-helper.i18n.yaml | 2 +- .../2026-07-04-fold-stdio-ui-helper.zh.md | 2 +- ...04-prune-producerless-vocabulary-variants.i18n.yaml | 2 +- ...-07-04-prune-producerless-vocabulary-variants.zh.md | 2 +- .../2026-07-04-remove-agent-steering-mirror.i18n.yaml | 2 +- .../2026-07-04-remove-agent-steering-mirror.zh.md | 4 ++-- ...2026-07-04-tighten-hook-protocol-contract.i18n.yaml | 2 +- .../2026-07-04-tighten-hook-protocol-contract.zh.md | 4 ++-- ...-12-drop-unconsumed-skill-provider-events.i18n.yaml | 2 +- ...6-07-12-drop-unconsumed-skill-provider-events.zh.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.i18n.yaml | 2 +- .../testing/2026-06-19-acp-snapshot-tests.zh.md | 2 +- .../2026-06-22-subagent-snapshot-replay.i18n.yaml | 2 +- .../testing/2026-06-22-subagent-snapshot-replay.zh.md | 2 +- .../testing/2026-07-04-hook-snapshot-matrix.i18n.yaml | 2 +- .../testing/2026-07-04-hook-snapshot-matrix.zh.md | 8 ++++---- .../2026-07-08-shared-acp-snapshot-package.i18n.yaml | 2 +- .../2026-07-08-shared-acp-snapshot-package.zh.md | 4 ++-- ...4-prune-unimplemented-subagent-vocabulary.i18n.yaml | 2 +- ...07-04-prune-unimplemented-subagent-vocabulary.zh.md | 2 +- 54 files changed, 80 insertions(+), 80 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 8f73ac8dbe..732d69205f 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-subagent-provider-lifecycle-events.md: afd45027e8b56cbf1d17e6dec749d8602c81124d -2026-07-05-subagent-provider-lifecycle-events.zh.md: be94de29d8fadafb08acc95f1eb15e2fb931b3bb +2026-07-05-subagent-provider-lifecycle-events.zh.md: 98bcaed31d88c0b6c10ffd7942e77f8048733302 diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index be94de29d8..98bcaed31d 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -31,6 +31,6 @@ Status: implemented ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 -- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/cordis-catalog/events.md)与[生产者/消费者映射](../../../../docs/event-producer-consumer.md)。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/cordis-catalog/events.md)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。 - **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持 prompt 组装的时效性。 - **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 2de6e74661..451843a4c0 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-agent-scope-runtime-design.md: ff7fba1e6f8d496080acbceddb06691c8cddc5f5 -2026-07-12-agent-scope-runtime-design.zh.md: 0a101e796658dd48bc76425fb39362f35e7e345e +2026-07-12-agent-scope-runtime-design.zh.md: e2aa894b638fb3183750bee96d8a678f25f53de1 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 0a101e7966..e2aa894b63 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -330,7 +330,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 ### 生成的产物使公开契约保持对齐 -事件目录、服务目录、生产者/消费者矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md)拥有 Program 构造、语义事件发现和解析器生成规则。 +事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md)拥有 Program 构造、语义事件发现和解析器生成规则。 行为测试固定了作用域路由和 dispose、最终入口碰撞清理、发布回滚、有序静默、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 8253a2ee5b..1d285ec9a1 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-core-data-structures-catalog.md: a2f5e0e7b06e34cb361e4944bbfa3692355a2cbe -2026-06-20-core-data-structures-catalog.zh.md: 5bd3b141df786066407264692d1f13e5c0643c01 +2026-06-20-core-data-structures-catalog.zh.md: f886ee18dd64c4a7cf972b3b141e2c20eaa0c7b1 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 5bd3b141df..f886ee18dd 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -50,7 +50,7 @@ Status: implemented 主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及 session/persistence 拆分的逐一测试。 -`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是清单点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为孤儿。本 Agent Note 将这条失败关闭扫描规则与主干/接缝、逐字匹配决策一并记录;生成式 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 +`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是清单点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为孤儿。本 Agent Note 将这条失败关闭扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成式 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml index b64d63b3ff..65d734319e 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-tool-schema-catalog.md: c8cc69df428f6eee0f66ed976865afe2a0702448 -2026-07-02-tool-schema-catalog.zh.md: 7caa9e8747d8fd09806342035d185e664f7be7b7 +2026-07-02-tool-schema-catalog.zh.md: 9fb92e7413fc60df894a53bc077336ba29825bc5 diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md index 7caa9e8747..9fb92e7413 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -目录通过**启动每个工具插件并读取其已注册 schema** 来生成,而不是解析源码。`scripts/gen-tool-catalog.ts` 在全新的 Cordis `Context` 上挂载每个已发布工具包(带有 `SystemPrompt`、`ToolRegistry` 以及插件 `apply` 所读取的注入接缝),调用 `ctx.tools.schemas()`——也就是发送给模型的确切 `ToolSchema[]`——随后释放上下文,并为每个包渲染一个 `## <package>` 章节,每个工具附带一个 ` ```json ` `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形状一致:默认 `--write` 重新生成;提交副本陈旧时 `--check` 失败;输出具有确定性(按清单排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一项新鲜度检查。 +目录通过**启动每个工具插件并读取其已注册 schema** 来生成,而不是解析源码。`scripts/gen-tool-catalog.ts` 在全新的 Cordis `Context` 上挂载每个已发布工具包(带有 `SystemPrompt`、`ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`——也就是发送给模型的确切 `ToolSchema[]`——随后释放上下文,并为每个包渲染一个 `## <package>` 章节,每个工具附带一个 ` ```json ` `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形状一致:默认 `--write` 重新生成;提交副本陈旧时 `--check` 失败;输出具有确定性(按清单排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一项新鲜度检查。 ### 为何启动而非解析(核心要点) diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml index 5a69a8e3e5..0173ef2543 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-03-documentation-graph-atlas.md: 9a30b13f9db6ceb2715517230e349cbe083850ec -2026-07-03-documentation-graph-atlas.zh.md: da0656a7b5b9f54c5c551e01382654966b900579 +2026-07-03-documentation-graph-atlas.zh.md: ffa29ec4b2237fe51282bac5dae4bbc5294b2824 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md index da0656a7b5..ffa29ec4b2 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -10,7 +10,7 @@ Status: implemented 这些参考文档是准确的,但大多是目录式的。维护者仍需自行综合关系:哪些包构成一个能力 seam、哪个应用组装了具体的主干、哪些事件是持久的而哪些是实时的、钩子或策略插件在哪里可以拦截工作、以及哪个面向模型的工具依赖哪个服务。SDK 用户从另一个角度面临同样的问题:「我想要某种行为,应该安装或加载哪个包?应该扩展哪个事件/服务/工具?」 -钩子子系统使事件的生产者/消费者拓扑与拦截点变得更加重要;文件系统 seam 使能力 seam、策略否决、工具呈现与 SDK 组装路径变得更加重要。如果关系图的范围仅限于一个小的 bash/todo/subagent 表面,它们会立即陈旧。 +钩子子系统使事件的生产者/消费方拓扑与拦截点变得更加重要;文件系统 seam 使能力 seam、策略否决、工具呈现与 SDK 组装路径变得更加重要。如果关系图的范围仅限于一个小的 bash/todo/subagent 表面,它们会立即陈旧。 ## 决策 @@ -34,12 +34,12 @@ Status: implemented |---|---|---| | [模块依赖图](../../../../docs/module-graph.md) | 生成式 | `packages/*/*/package.json` 的 peer dependency 与包分组路径 | | [工具 schema 目录与包映射](../../../../docs/tool-catalog.md) | 生成式 | 启动后采集的工具 schema,以及工具包服务/效应元数据 | -| [能力接缝与核心服务](../../../../docs/capability-seams.md) | 混合生成式 | Cordis 服务声明,以及 `gen-doc-graphs.ts` 中的角色清单 | +| [能力 seam 与核心服务](../../../../docs/capability-seams.md) | 混合生成式 | Cordis 服务声明,以及 `gen-doc-graphs.ts` 中的角色清单 | | [tui-agent 应用组合](../../../../examples/tui-agent/composition.md) | 混合生成式 | `examples/tui-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [headless-agent 应用组合](../../../../examples/headless-agent/composition.md) | 混合生成式 | `examples/headless-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [cordis-agent 应用组合](../../../../examples/cordis-agent/composition.md) | 混合生成式 | `examples/cordis-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | 混合生成式 | `examples/acp-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | -| [事件生产者/消费者矩阵](../../../../docs/event-producer-consumer.md) | 混合生成式 | Cordis 事件声明、经 AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 位置,以及显式动态分派覆盖 | +| [事件生产者/消费方矩阵](../../../../docs/event-producer-consumer.md) | 混合生成式 | Cordis 事件声明、经 AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 位置,以及显式动态分派覆盖 | | [agent 轮次与步骤生命周期](../../../../docs/agent-lifecycle.md) | 人工维护 | architecture.md 循环生命周期、Cordis 目录链接,以及 session 事件语义 | | [工具执行管线](../../../../docs/tool-execution-pipeline.md) | 人工维护 | 工具管线语义与 `tools/execute` waterfall(瀑布式事件)| | [ACP(Agent Client Protocol)快照回放](../../../../packages/ui/acp/snapshot-replay.md) | 人工策划 | 快照 harness 行为 | @@ -55,12 +55,12 @@ Status: implemented - 模块图读取每个包的 `peerDependencies`,并按 `packages/<group>/<pkg>` 路径对包进行分组。 - 工具目录通过启动收集已发布的工具,并从同一份 manifest 渲染包/服务/副作用映射(其完整性守卫已在检查该 manifest)。 - 能力 seam 图导入 Cordis 服务收集器,断言每个发现的 harness `ctx.<key>` 都已在 `SERVICE_ROLES` 中分类,且每个已分类的 key 仍然存在。 -- 事件生产者/消费者矩阵标记为 hybrid,因为 subagent 生命周期事件有意使用 `ctx.events.dispatch` 实现逐监听器隔离;这些动态边是显式覆盖而非无声遗漏。 +- 事件生产者/消费方矩阵标记为 hybrid,因为 subagent 生命周期事件有意使用 `ctx.events.dispatch` 实现逐监听器隔离;这些动态边是显式覆盖而非无声遗漏。 - `verify-mermaid` 使用 Mermaid 自身的解析器解析仓库中每个 ` ```mermaid ` 围栏,因此语法错误在本地和 CI 的 `doc-sync` 阶段即被捕获,而非在 GitHub 渲染时才显示为损坏的图表。 ## 曾考虑的替代方案 -已提交的图表使用 Mermaid,因为 GitHub 在 Markdown 中原生渲染它且不引入新的文档构建依赖;密集的多对多数据(如事件生产者/消费者关系)改用 Markdown 表格。**PlantUML、托管图表服务和生成的 SVG** 曾被考虑,但在 Mermaid 成为瓶颈之前有意不采用。 +已提交的图表使用 Mermaid,因为 GitHub 在 Markdown 中原生渲染它且不引入新的文档构建依赖;密集的多对多数据(如事件生产者/消费方关系)改用 Markdown 表格。**PlantUML、托管图表服务和生成的 SVG** 曾被考虑,但在 Mermaid 成为瓶颈之前有意不采用。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml index d8ebe51c67..8986227bd3 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-parallel-github-ci-gates.md: 5c276f6a75936021369bc5ad9494c9aa6e4e3fc3 -2026-07-06-parallel-github-ci-gates.zh.md: f96606ba2b58b856b3833e758853e9fa3a62bff3 +2026-07-06-parallel-github-ci-gates.zh.md: 6f46dd79e41b46d29fd4ed9f98009e0503f1be04 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md index f96606ba2b..6f46dd79e4 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md @@ -10,7 +10,7 @@ Status: implemented 随着 workspace 增长,原有的宽车道拆分不再满足这一平衡。PR(Pull Request)#404 合并时,Linux 的静态、覆盖率、快照和产物 job 分别耗时 148、195、94 和 230 秒;Windows 的静态和产物 job 分别耗时 251 和 482 秒。每个包都调用一次包管理器打包,主导了两个产物验证器的耗时;覆盖率在仅运行源码的套件前无谓地重建输出;CPU 密集型门禁则在静态与覆盖率车道内争用资源。 -产物边界仍然承载关键约束。`publint`、`verify-node-next-types`、已编译不变量加载和已构建二进制冒烟测试都需要生成的 `lib/` 输出。分片不能让这些消费者抢在构建前运行,也不能用源码执行取代它们对已发布产物的信号。 +产物边界仍然承载关键约束。`publint`、`verify-node-next-types`、已编译不变量加载和已构建二进制冒烟测试都需要生成的 `lib/` 输出。分片不能让这些消费方抢在构建前运行,也不能用源码执行取代它们对已发布产物的信号。 ## 决策 @@ -24,7 +24,7 @@ Status: implemented 冷启动的独立文档类型检查会重建完整的项目引用图,因此专用文档类型车道只构建一次,再用这些声明检查 Markdown 块。Linux 文档车道使用 VitePress 的 MPA 构建,在观测所得的非 Windows 目标内保留页面渲染与死链接验证;单独的阻塞式 Windows 构建和生产站点车道保留已生成包与已发布站点检查,同时避免把两条关键路径放进同一个 job。 -产物使用两个车道:一个元数据车道负责 `publint`、NodeNext 声明和已编译不变量加载,另一个负责已构建二进制冒烟。每个车道都会在其消费者之前自行构建。重复短时构建会消耗 runner 分钟数,但避免了上传/下载依赖,并使每个 job 的关键路径保持有界。 +产物使用两个车道:一个元数据车道负责 `publint`、NodeNext 声明和已编译不变量加载,另一个负责已构建二进制冒烟。每个车道都会在其消费方之前自行构建。重复短时构建会消耗 runner 分钟数,但避免了上传/下载依赖,并使每个 job 的关键路径保持有界。 [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 在进程内针对内存发布视图调用 publint 支持的 API;该视图由每份清单声明的文件和 npm 强制元数据文件构成。这样无需生成 103 次包管理器打包命令,也能保留 workspace 文件与已发布文件之间的区别。[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) 在真实包下暂存这些经过结构验证、由清单声明的 `lib/` 文件,再通过纯 Node 和 Cordis Loader 规范化导入已编译的自引用。若伴随项触及未声明的运行时 chunk,仍会失败。 @@ -36,7 +36,7 @@ Status: implemented - **保留宽车道**:最大限度减少工作流 YAML,但会保留观测到的数分钟反馈周期。 - **让每个叶子门禁分别成为 GitHub job**:最大化扇出,但短小的生成器和正文检查准备 runner 的时间会超过检查仓库的时间。 -- **向产物消费者上传一次构建**:避免重复编译,但上传/下载和依赖调度会延长墙钟时间;干净构建足够短,可以在有界车道内重复。 +- **向产物消费方上传一次构建**:避免重复编译,但上传/下载和依赖调度会延长墙钟时间;干净构建足够短,可以在有界车道内重复。 - **在两个发布门禁中保留包管理器打包**:把清单选择委托给 pnpm,但会重复启动 200 多个包管理器进程。清单结构门禁加发布视图 fixture 使优化后的清单契约显式化,并会在存在磁盘上有但未发布的依赖时失败。 - **在覆盖率前保留构建**:提供源码套件已不再消费的生成输出;干净树覆盖率证明表明这只是纯粹的延迟。 - **在每个 Node 版本上执行类型检查**:重复编译器工作,而兼容性冒烟已经验证实际的 Node 特有加载与压缩行为。 diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml index 4234c01a0c..4c43b4d233 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-10-readme-known-limitations-gate.md: 2ca1168d795692730d17b6ab23dd113e8be277e5 -2026-07-10-readme-known-limitations-gate.zh.md: 7968a30b996b931169bbe2678ec1500ff86ef577 +2026-07-10-readme-known-limitations-gate.zh.md: a64f15d9c6f1b48413e06d2a46b33f6f228f2f02 diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md index 7968a30b99..a64f15d9c6 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`packages/<group>/<pkg>/package.json` 下的每份包清单都有一个同级 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。其项目符号记录由该包拥有的持久消费者缺口和不明显的维护者约束;普通清理仍留在源码 TODO 或所属 Agent Note(agent 决策记录)中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从清单推导包集合,拒绝缺失 README,并要求恰好一个规范 h2 且至少包含一个顶层项目符号。“Limitations”“Deferred”“What is NOT here”或“Non-goals”等近似标题都会失败。 +`packages/<group>/<pkg>/package.json` 下的每份包清单都有一个同级 README,其中包含规范的 `## Known Limitations and Deferred Work` 章节。其项目符号记录由该包拥有的持久消费方缺口和不明显的维护者约束;普通清理仍留在源码 TODO 或所属 Agent Note(agent 决策记录)中。[`verify-package-readme-limitations` 门禁](../../../../scripts/verify-package-readme-limitations.ts)从清单推导包集合,拒绝缺失 README,并要求恰好一个规范 h2 且至少包含一个顶层项目符号。“Limitations”“Deferred”“What is NOT here”或“Non-goals”等近似标题都会失败。 如果一个包确实没有需要声明的限制事项,则将其列入 `NO_LIMITATIONS` 并省略该章节。新增限制事项时须移除该条目;重命名或移除条目会失败,因为每个条目都必须对应一个被扫描的包。 diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml index 903114914c..5c2bde0225 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-package-model-experience-contract.md: 92a8e5a1a81d00dae085e4af89456896373058e6 -2026-07-12-package-model-experience-contract.zh.md: d81e87f1cb52cc77800ad5e6256796aa389f3215 +2026-07-12-package-model-experience-contract.zh.md: e7a8db794e3e83021fdcab8fc94c4a4f67abb842 diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md index d81e87f1cb..e7a8db794e 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -包 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的问题:该包的哪些内容会进入模型请求、在什么条件下进入、这些 token 会保留多久,以及后续请求是否会保留可复用的 KV cache 前缀。在插件架构中,这种遗漏尤其难以审计。消费者可能把后端结果转为工具消息,策略插件可能以错误取代成功结果,压缩可能移除旧历史,而 agent 范围的注册可能改变某个 agent 的 prompt 或 schema,却不影响其他 agent。因此,只阅读名义上面向模型的包会遗漏真实的上下文效应,而在每次常规评审中跨所有依赖阅读源码又成本过高。 +包 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的问题:该包的哪些内容会进入模型请求、在什么条件下进入、这些 token 会保留多久,以及后续请求是否会保留可复用的 KV cache 前缀。在插件架构中,这种遗漏尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能以错误取代成功结果,压缩可能移除旧历史,而 agent 范围的注册可能改变某个 agent 的 prompt 或 schema,却不影响其他 agent。因此,只阅读名义上面向模型的包会遗漏真实的上下文效应,而在每次常规评审中跨所有依赖阅读源码又成本过高。 ## 决策 @@ -14,7 +14,7 @@ Status: implemented 具有直接、条件式、有上限、全生命周期、多表面或辅助模型效应的包,为每个上下文表面使用一个 H3。每个表面包含三个有序 H4 字段——`What the model sees`、`Token effect` 和 `KV Cache effect`——每个字段都以一个正文段落开头。cache 字段区分仅追加增长、稳定重复前缀、替换先前 token,以及独立模型请求;它点明由包拥有、且能在新内容追加前改变请求的每项配置、范围、生命周期、压缩或路由变化。“Does not invalidate”表示该包保留一个已经可复用的前缀,并非承诺 provider 一定命中 cache 或保留某段时间。由包拥有的稳定文本按原文精确引用:system prompt 正文和其他长字面量在引入它们的字段下使用带标题的 H5 加 `markdown` 围栏,通常位于 `What the model sees`;短字面量则以内联形式保留,并点名插值占位符。工具 schema 表面链接生成式[工具目录](../../../../docs/tool-catalog.md)中带锚点的章节,并且只陈述组合或配置增量;仅运行时定义解释目录为何省略它们。依赖数据和由 provider 拥有的文本采用摘要。agent 范围的可见性须显式说明;当范围可隐藏 prompt 与 schema 中的一者而不影响另一者时,两种表面保持分离。 -没有模型上下文效应的包,或某条路径完全由另一个包渲染的包,使用验证器审计过的短格式:一句以 `None, as ` 或 `Indirectly, through ` 开头的句子,随后是一个 `KV Cache effect` H4 和一个正文段落。纯传输包和无密钥测试支持包若不创建任何进入模型的内容,就使用 none 格式。provider 后端即使会限制或过滤数据也使用 indirect 格式;具名子项拥有全部效应时,接线 bundle 也使用该格式。这些章节定位贡献并声明不会直接使 cache 失效,同时不重复陈述消费者。结构化章节同样只记录由包拥有的输入、变换和增量。 +没有模型上下文效应的包,或某条路径完全由另一个包渲染的包,使用验证器审计过的短格式:一句以 `None, as ` 或 `Indirectly, through ` 开头的句子,随后是一个 `KV Cache effect` H4 和一个正文段落。纯传输包和无密钥测试支持包若不创建任何进入模型的内容,就使用 none 格式。provider 后端即使会限制或过滤数据也使用 indirect 格式;具名子项拥有全部效应时,接线 bundle 也使用该格式。这些章节定位贡献并声明不会直接使 cache 失效,同时不重复陈述消费方。结构化章节同样只记录由包拥有的输入、变换和增量。 `verify-package-readme-model-experience` 发现包清单,并验证三种分类、规范末尾章节顺序、确切字段标题深度与顺序、非空字段段落、逐字块的 H5 归属、具体字面量证据,以及带锚点的工具目录链接。它在 `doc-sync` 和并行门禁 runner 中运行。评审仍负责覆盖面、链接相关性和事实准确性。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml index 012f47ae00..0a33c09edb 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unconsumed-llm-adapter-change-event.md: a3c7c089d7dfa1a4cd6a891c416bf270dc7eff3d -2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: 8839a4c263462f2bae75d8698b20007e8348d903 +2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: b0dc96897d14a032db857ee820d082afbea8f6a6 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md index 8839a4c263..b0dc96897d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md @@ -8,7 +8,7 @@ Status: implemented `LlmService.registerAdapter()` 在注册和 dispose(资源释放)时发出 `llm/adapter-change` 事件([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))。在 `packages/*/src` 和 `examples/*/src` 中搜索 `llm/adapter-change`,只能找到声明、emit 站点、文档和测试;没有任何生产环境的监听器订阅它。 -这与 `tools/change` 和 `system-prompt/change` 不同。如今这两个事件同样没有消费者,但它们有望成为未来实时工具/prompt UI 的注册表变更信号。LLM(大语言模型)adapter 注册更像是启动时的实现细节:adapter 不是用户可见的选项面板,真正的模型调用拦截接缝是 `llm/stream`。保留一个没有监听器的 adapter 变更事件,只是在更小范围内重复[删除无用 summary](2026-06-19-drop-mutable-session-summary.md) 的模式。 +这与 `tools/change` 和 `system-prompt/change` 不同。如今这两个事件同样没有消费方,但它们有望成为未来实时工具/prompt UI 的注册表变更信号。LLM(大语言模型)adapter 注册更像是启动时的实现细节:adapter 不是用户可见的选项面板,真正的模型调用拦截 seam 是 `llm/stream`。保留一个没有监听器的 adapter 变更事件,只是在更小范围内重复[删除无用 summary](2026-06-19-drop-mutable-session-summary.md) 的模式。 这个事件并非零成本。`registerAdapter()` 在发出 `llm/adapter-change` 之前先 yield 回滚 disposer,这样抛出异常的监听器会回退变更而非泄漏适配器条目;包内还有针对该监听器抛出路径的测试。这种防御性排序保护的是一个只有测试才能触发的失败模式。 @@ -20,7 +20,7 @@ Status: implemented ### 为什么不移除所有注册表变更事件? -由注册表通告变更的微内核是一种一致的约定。当 UI 能够实时刷新可用工具或 prompt 章节时,`tools/change` 和 `system-prompt/change` 可能会有用。本 Agent Note(agent 决策记录)在存在合理用户侧消费者的位置保留该约定,只删除当前及可能的未来消费者都不明确的 adapter 变更事件。 +由注册表通告变更的微内核是一种一致的约定。当 UI 能够实时刷新可用工具或 prompt 章节时,`tools/change` 和 `system-prompt/change` 可能会有用。本 Agent Note(agent 决策记录)在存在合理用户侧消费方的位置保留该约定,只删除当前及可能的未来消费方都不明确的 adapter 变更事件。 如果将来需要 LLM 适配器浏览器或动态模型选择器用到此信号,届时再连同消费方一起重新引入,并提供比「something changed」更清晰的 payload。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml index 0fdd9342a5..b8c3abac63 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: b6b596e822b4bd6fd1bd891c336c622ad675ad45 -2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 5a4e378958f5fb594345305804a3b90e493f0560 +2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 6ca867767a7b74218b974c17b4c14bc733e3cd7d diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md index 5a4e378958..6ca867767a 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md @@ -14,7 +14,7 @@ Status: implemented LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体循环),它只使用 `stream()`:将原始分片送入自己的 `BlockAssembler`,以便在并行组装的同时记录分片,保证回放保真度([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts),`ctx.llm.stream(req)` 步骤)。在 `packages/*/src` 和 `examples/*/src` 中 grep `streamBlocks` 与 `ctx.llm.generate`,找不到任何生产调用方。仅有的引用来自服务方法定义、文档和测试;适配器测试用 `generate()` 作为便捷驱动,但它们完全可以通过同一个 assembler 辅助函数手动消费 `stream()`,无需为此保留一个公开的生产 API。 -这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:带有受测契约的组装视图 API,由测试而非生产代码消费。它们是为不关心 token 级增量的消费者推测性构建的,但唯一的真实消费者恰恰关心增量,以便持久化高保真重放数据。 +这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:带有受测契约的组装视图 API,由测试而非生产代码消费。它们是为不关心 token 级增量的消费方推测性构建的,但唯一的真实消费方恰恰关心增量,以便持久化高保真重放数据。 `streamBlocks()` 拖带了 `BlockAssembler` 的一块专用逻辑:`flushReady()` 与 `flushRemaining()`([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts))以及 `flushed` 游标字段,仅为支持按序增量产出而存在。`generate()` 拖带了 `GenerateResult`、`BlockAssembler.result()` 以及 `llm/generate` waterfall——在同一底层流之上的第二个拦截面。agent loop 对 assembler 的使用仅限于 `push()` / `message()` / `usage` / `finish`,不涉及流式 flush 或一次性服务组装。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml index a2d0e004e3..91b82dcd75 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-prune-dead-seam-methods.md: bb91194ed0483ca4c43acdde8370e7152ac876ea -2026-06-20-prune-dead-seam-methods.zh.md: d64e9b5b6d40fbb0f52705a3385123b072360607 +2026-06-20-prune-dead-seam-methods.zh.md: f441ac0d91b4f673cbbbd4148c9183cc1185a54f diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md index d64e9b5b6d..f441ac0d91 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md @@ -4,11 +4,11 @@ Status: implemented [English](2026-06-20-prune-dead-seam-methods.md) | 中文 -> **实现说明:** 仅移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 仍然保留,因为删除它们的单行查找表面会要求消费者增加显著更多的完成跟踪机制。其 id 品牌化由[品牌化 id Agent Note(agent 决策记录)](../architecture/2026-06-20-branded-ids.md)负责。 +> **实现说明:** 仅移除了 `SessionPersistence.has()` 和 `.delete()`。`BashExecutor.get()` 和 `.list()` 仍然保留,因为删除它们的单行查找表面会要求消费方增加显著更多的完成跟踪机制。其 id 品牌化由[品牌化 id Agent Note(agent 决策记录)](../architecture/2026-06-20-branded-ids.md)负责。 ## 问题 -能力接缝([接口 / 实现 / 消费者](../architecture/2026-06-13-capability-seams.md))承载了没有消费者调用的抽象方法。接缝的存在是为了让实现和消费者独立演进——但没有消费者以之编程的方法不是接缝,而是每个实现仍必须实现和测试的推测性表面。 +能力 seam([接口 / 实现 / 消费方](../architecture/2026-06-13-capability-seams.md))承载了没有消费方调用的抽象方法。seam 的存在是为了让实现和消费方独立演进——但没有消费方以之编程的方法不是 seam,而是每个实现仍必须实现和测试的推测性表面。 ### `SessionPersistence.has()` 与 `.delete()` @@ -20,8 +20,8 @@ Status: implemented 没有消费方使用的方法被移除——从抽象 seam、实现,以及仅为覆盖它们而存在的契约/spec 测试套件中移除: -- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` hook 均消失(jsonl 和 sqlite 都只是为了满足该 hook 才实现 `deleteStored`,这些实现也一并移除)。后端属于[双后端](../architecture/2026-06-14-session-persistence.md)设计,其他方面不在范围内;删除它们为没有消费者的 hook 所做的实现,是删除 hook 的一部分,而非重新设计后端。 -- 所有文档和源码注释引用都已更新为保留下来的四方法、仅含 `list()` 的契约——不仅包括字面上的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和“六个公共方法”的计数——涉及接缝和后端 README、[docs/architecture.md](../../../../docs/architecture.md)、[session persistence](../architecture/2026-06-14-session-persistence.md) 与[写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md) Agent Note,以及协调器/后端 JSDoc。 +- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` hook 均消失(jsonl 和 sqlite 都只是为了满足该 hook 才实现 `deleteStored`,这些实现也一并移除)。后端属于[双后端](../architecture/2026-06-14-session-persistence.md)设计,其他方面不在范围内;删除它们为没有消费方的 hook 所做的实现,是删除 hook 的一部分,而非重新设计后端。 +- 所有文档和源码注释引用都已更新为保留下来的四方法、仅含 `list()` 的契约——不仅包括字面上的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和“六个公共方法”的计数——涉及 seam 和后端 README、[docs/architecture.md](../../../../docs/architecture.md)、[session persistence](../architecture/2026-06-14-session-persistence.md) 与[写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md) Agent Note,以及协调器/后端 JSDoc。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index a6fd8f47e0..dd6d9f1aa1 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-public-agent-stop-surface.md: 81a21de30bfbc25688069efbffb21647889b1bdb -2026-06-20-public-agent-stop-surface.zh.md: 78fd2e5f0392b2632671311420eca8f5ad96c501 +2026-06-20-public-agent-stop-surface.zh.md: 8b39646bb5d012fee20fac6765dcfdd22ffb93b6 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index 78fd2e5f03..8b39646bb5 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -20,7 +20,7 @@ Status: implemented `whenIdle()` **保留**为公开的静默观测原语(agent 从 `running` 状态稳定后 resolve,已处于 idle 时立即 resolve,dispose 后等待循环退出)。它不是停止动词;它是非所有者在不 dispose agent 的前提下观测停止*完成*的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 销毁它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 -公共 `abort()` 已不存在,disposer 仍为异步并等待循环停止。测试通过公共类型化原因和显式 signal 接缝验证取消,而不会伸入 holder 内部。 +公共 `abort()` 已不存在,disposer 仍为异步并等待循环停止。测试通过公共类型化原因和显式 signal seam 验证取消,而不会伸入 holder 内部。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 0dc354fa47..829017fe21 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-agent-boundary-mirror-events.md: 8c5bb74f2347fe0269cbb9c6504137de761ab919 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: 37d9d1798bb00d66c0dfd5211ed1932522ce228a +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 8e659adb810fd2668de57a123cd53432f137b08a diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index 37d9d1798b..8e659adb81 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -15,7 +15,7 @@ Status: implemented ## 问题 -循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费者在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择 session log,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费者;它已经从 `session/event` 渲染工具调用和结果。 +循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费方在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择 session log,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费方;它已经从 `session/event` 渲染工具调用和结果。 这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 @@ -25,7 +25,7 @@ Status: implemented 四个持久边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——已从 agent(智能体)事件分类中移除。希望在边界处取得 agent handle 的 UI 会保留来自 `agent/created`/`agent/disposed` 的实时目标对象,并直接比较其 session;`dsh-ui-stdio` 据此为应用拥有的 agent 标记 `[main turn N]` 头部,其他 session 则渲染其持久 id。规范记录仍是事件溯源 session log。 -步骤镜像(完全没有消费者)最先在[事件域语义 Agent Note(agent 决策记录)](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在轮次边界取得 `Agent` handle 为由,保留了轮次镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此“ui-stdio 需要它”并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 +步骤镜像(完全没有消费方)最先在[事件域语义 Agent Note(agent 决策记录)](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在轮次边界取得 `Agent` handle 为由,保留了轮次镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此“ui-stdio 需要它”并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 ## 范围:移除什么、不移除什么 @@ -40,8 +40,8 @@ Status: implemented ## 曾考虑的替代方案 - **将 `agent/steering` 一并移除**——原始提案的形状;作为范围蔓延被排除:它镜像持久的 `steering/message` 控制记录,而非边界,后来由[自己的决策](2026-07-04-remove-agent-steering-mirror.md)移除(`agent/stream-chunk` 也由 [stream chunk 镜像 Agent Note](2026-07-02-remove-stream-chunk-mirror.md) 移除)。 -- **为 stdio UI 保留轮次镜像**——[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的原始立场;在此否决,因为 `dsh-ui-stdio` 是可随时丢弃的测试 REPL,而非承载关键约束的消费者,并且它改为根据 `session/event` 加自己的实时目标对象渲染边界。 +- **为 stdio UI 保留轮次镜像**——[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的原始立场;在此否决,因为 `dsh-ui-stdio` 是可随时丢弃的测试 REPL,而非承载关键约束的消费方,并且它改为根据 `session/event` 加自己的实时目标对象渲染边界。 ## 后果 -插件不能再从便捷的 `Agent` 优先事件观察轮次/步骤边界。它需要订阅 `session/event`;如果需要实时对象,则通过 `ctx.agents` 解析共享 id,或保留自己已经拥有的对象。这是可以接受的取舍:边界消费者不应依赖可能与持久日志发生漂移的第二条事件 feed。 +插件不能再从便捷的 `Agent` 优先事件观察轮次/步骤边界。它需要订阅 `session/event`;如果需要实时对象,则通过 `ctx.agents` 解析共享 id,或保留自己已经拥有的对象。这是可以接受的取舍:边界消费方不应依赖可能与持久日志发生漂移的第二条事件 feed。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml index f52f1e62c4..02addf9d6a 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-unify-agent-and-session-id.md: c55152f4f13fe0acb530503e84f465799007cff7 -2026-06-20-unify-agent-and-session-id.zh.md: 46e786a8f65b33e071fe689922ab396be90a184a +2026-06-20-unify-agent-and-session-id.zh.md: 943d424ace4544d4c3a6787435b0bf95d99d7426 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md index 46e786a8f6..943d424ace 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -一个实时 agent(智能体)/session 对需要使用同一 identity 完成注册表路由、事件溯源和持久化。让 factory 接受相互独立的 `agentId` 和 `sessionId` 输入,会允许任何生产路径都无法使用的配对,同时迫使每个消费者为同一生命周期在两个名称之间选择或转换。 +一个实时 agent(智能体)/session 对需要使用同一 identity 完成注册表路由、事件溯源和持久化。让 factory 接受相互独立的 `agentId` 和 `sessionId` 输入,会允许任何生产路径都无法使用的配对,同时迫使每个消费方为同一生命周期在两个名称之间选择或转换。 ACP(Agent Client Protocol)对两种 identity 使用相同值。Stdio 和 hook 也在 session 事件流上工作,并且直接需要对应的实时 agent;没有生产路径会把一个实时 agent 对象重新附着到多个 session,或通过多个 agent id 驱动一个 session。 @@ -20,7 +20,7 @@ agent 的注册表 id 等于其 session id。`CreateAgentOptions` 接受一个 ` 配置驱动路径保留 `agents[].id` 作为稳定配置标签,而非实时路由 identity。普通的全新启动会铸造组合 id `${label}-session-${randomUUID()}`,使持久重启不会冲突。耦合应用可以预先铸造并传入精确的 `sessionId`:首次使用时创建它,而当持久化服务已经存在时,AgentLoop 重新挂载会在同一 identity 下恢复已物化历史。`resumeSessionId` 则要求已有的持久化 identity。两个精确 id 输入互斥。Stdio 使用“恢复或创建”形式,使配置创建的 agent 和 UI 在循环重载之间共享一个不透明 identity,而不是根据前缀猜测。日志可以使用稳定标签,而所有实时与持久查找都使用同一个 `SessionId`。 -`agent/created` 和 `agent/disposed` 保留。它们是成对的发布生命周期事件,而非 identity 别名;以后若发现没有消费者并要移除,必须先重新搜索,再提出独立提案。 +`agent/created` 和 `agent/disposed` 保留。它们是成对的发布生命周期事件,而非 identity 别名;以后若发现没有消费方并要移除,必须先重新搜索,再提出独立提案。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml index cc7da6603e..08448a9541 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-26-fsspec-style-fs-seam.md: d496f273e2635624e0ab8e70e06c8729563c5466 -2026-06-26-fsspec-style-fs-seam.zh.md: d6217e768fe4a11a7f6aacf8a17bb2e9e232a44d +2026-06-26-fsspec-style-fs-seam.zh.md: 54d08b28a7004b47f20f4947a39264819260d9bf diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md index d6217e768f..54d08b28a7 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[文件系统能力接缝](../architecture/2026-06-17-filesystem-capability-seam.md)中的文件系统能力目前让一个抽象 `FileSystem` 服务同时负责两项不同工作: +[文件系统能力 seam](../architecture/2026-06-17-filesystem-capability-seam.md)中的文件系统能力目前让一个抽象 `FileSystem` 服务同时负责两项不同工作: 1. **提供方操作**——解析目标、stat/版本元数据、文本读取/流式读取、原子写入,以及受保护的字面编辑。 2. **面向 agent(智能体)的策略**——行窗口、字面编辑语义,以及读后写/编辑的观测状态。 @@ -99,7 +99,7 @@ type FsWriteIntent = ## 取代 -本 Agent Note 推翻[文件系统能力接缝](../architecture/2026-06-17-filesystem-capability-seam.md)中的两项决策,并收窄第三项: +本 Agent Note 推翻[文件系统能力 seam](../architecture/2026-06-17-filesystem-capability-seam.md)中的两项决策,并收窄第三项: - 读后写/编辑策略从 `ctx.fs` 移出,进入 `dsh-fs-policy` 插件(通过 `fs/*` 事件门控)。 - 文本读取不再返回后端编号的行记录或 `full`/`partial` 视图;授权基于版本新鲜度,因此窗口化读取在文件未变时即可授权编辑。 @@ -113,7 +113,7 @@ type FsWriteIntent = ## 后续扩展 -后来,[为文件系统接缝添加直接目录列表](../architecture/2026-07-03-filesystem-directory-listing-seam.md)进一步扩展了该接缝。该后续工作单独跟踪,使本 Agent Note 的验收标准继续描述最初落地的 fsspec 风格改造。 +后来,[为文件系统 seam 添加直接目录列表](../architecture/2026-07-03-filesystem-directory-listing-seam.md)进一步扩展了该 seam。该后续工作单独跟踪,使本 Agent Note 的验收标准继续描述最初落地的 fsspec 风格改造。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 761fd95e87..8606c3ccfe 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-remove-stream-chunk-mirror.md: 5dd816940a4b2c2b63980e01f1bd4e0aac53a3e2 -2026-07-02-remove-stream-chunk-mirror.zh.md: a474238f19d58e261afba30227e5b1eaa25fcf7a +2026-07-02-remove-stream-chunk-mirror.zh.md: 7d0559ec18b52fe37340eea9e772ab92bc8871ed diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index a474238f19..7d0559ec18 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -19,7 +19,7 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror 实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方直接丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 -这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复相同:消费者面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把 chunk 流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将 chunk 流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 +这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复相同:消费方面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把 chunk 流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将 chunk 流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 推迟所依赖的前提已经明确:chunk 持久化是权威的,且将保留。停止持久化 chunk、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 @@ -44,4 +44,4 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror ## 后果 -插件不能再从 `Agent` 优先事件观察 token 增量。它需要订阅 `session/event`、过滤 `assistant/chunk`,并在需要时通过 `ctx.agents.get(session.id)` 直接查找对应的实时 handle。没有生产消费者需要在 chunk 时刻取得实时 `Agent`;这与移除边界镜像所作的取舍相同,均可接受。 +插件不能再从 `Agent` 优先事件观察 token 增量。它需要订阅 `session/event`、过滤 `assistant/chunk`,并在需要时通过 `ctx.agents.get(session.id)` 直接查找对应的实时 handle。没有生产消费方需要在 chunk 时刻取得实时 `Agent`;这与移除边界镜像所作的取舍相同,均可接受。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml index 5344a28971..3aa46c2fc0 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-drop-unconsumed-web-observation-surface.md: 5b1cb1307c63ef7c298200ee1655119026b9ebf5 -2026-07-04-drop-unconsumed-web-observation-surface.zh.md: a48e74eeb0f718c3511e2b1ad8e2dcde635350e1 +2026-07-04-drop-unconsumed-web-observation-surface.zh.md: ea97b59332d2c78810d0a6c43f2b7d99d96ccc5f diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md index a48e74eeb0..ea97b59332 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md @@ -9,11 +9,11 @@ Status: implemented `WebService` 暴露了一组没有任何生产代码观测的观测接口: - **`web/providers-change`**(`packages/web/web/src/index.ts`)在每次 provider 注册和 dispose(资源释放)时声明并发出,且每个注册 effect 的回滚 yield 被刻意排在 emit 之前,唯一目的是让抛出异常的 change listener 能回退注册。在该包自身的两个单元测试之外没有任何 listener(其中一个测试的存在仅仅是为了固定那个回滚顺序)。 -- **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一包)没有生产调用方:`dsh-tool-web` 直接通过 `ctx.web.search()`/`fetch()` 执行,并把不可用性呈现为接缝在执行时抛出的结构化 `WebError` code(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自己的测试。`packages/web/tool-web/README.md` 和 [architecture.md](../../../../docs/architecture.md) 中的正文声称工具“只读取聚合的 `searchStatus()`/`fetchStatus()`”——这种漂移之所以存续,只是因为没有机制对照调用位置检查正文。 +- **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一包)没有生产调用方:`dsh-tool-web` 直接通过 `ctx.web.search()`/`fetch()` 执行,并把不可用性呈现为 seam 在执行时抛出的结构化 `WebError` code(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自己的测试。`packages/web/tool-web/README.md` 和 [architecture.md](../../../../docs/architecture.md) 中的正文声称工具“只读取聚合的 `searchStatus()`/`fetchStatus()`”——这种漂移之所以存续,只是因为没有机制对照调用位置检查正文。 seam 自身的设计使这两个接口天然没有消费方:工具注册跟随产品 ENABLEMENT 而非 provider 可用性(`packages/web/tool-web/src/index.ts`),provider 选择在执行时解析且从不缓存——因此没有需要失效的缓存、没有需要重算的注册集合、也没有调用方需要一个有别于「执行并路由结构化错误」的可用性探测。HMR(热模块替换)清理由 effect disposer 自身承载。 -这与[删除无人消费的 `llm/adapter-change` 事件](2026-06-20-drop-unconsumed-llm-adapter-change-event.md)相呼应;后者从 `LlmService` 移除了相同的通知形状、相同的 emit 前回滚机制和相同的监听器抛错测试。该 Agent Note(agent 决策记录)的保留/删除标准是:为可能面向用户的工具列表消费者保留 `tools/change`,删除启动时后端注册表信号。按这一标准,web provider 注册表明确属于删除一侧;status 方法则是把同一判断应用于拉取表面,而非推送表面。 +这与[删除无人消费的 `llm/adapter-change` 事件](2026-06-20-drop-unconsumed-llm-adapter-change-event.md)相呼应;后者从 `LlmService` 移除了相同的通知形状、相同的 emit 前回滚机制和相同的监听器抛错测试。该 Agent Note(agent 决策记录)的保留/删除标准是:为可能面向用户的工具列表消费方保留 `tools/change`,删除启动时后端注册表信号。按这一标准,web provider 注册表明确属于删除一侧;status 方法则是把同一判断应用于拉取表面,而非推送表面。 ## 决策 @@ -23,7 +23,7 @@ seam 自身的设计使这两个接口天然没有消费方:工具注册跟随 ### 为什么不保留? -web 接缝 Agent Note 刻意规定了两者——事件作为最小 HMR 可见性信号,status 方法作为工具的聚合诊断——未来也可以设想 provider 状态面板。但同一 Agent Note 的其他选择让它们失去了生存条件:调用时派生选择和基于启用状态的注册,使任何消费者都不可能需要其中任一项;已发布工具展示了真实模式(执行并路由结构化错误);发生漂移的 README 句子则表明承诺中的消费者从未出现。按照 AGENTS.md 所述“Agent Note 是提案,而非绝对真理”,代码后来证明提案中的这些部分超出了需要;未来的观察者应根据真实消费者的形状,重新引入它实际消费的最小信号或查询。 +web seam Agent Note 刻意规定了两者——事件作为最小 HMR 可见性信号,status 方法作为工具的聚合诊断——未来也可以设想 provider 状态面板。但同一 Agent Note 的其他选择让它们失去了生存条件:调用时派生选择和基于启用状态的注册,使任何消费方都不可能需要其中任一项;已发布工具展示了真实模式(执行并路由结构化错误);发生漂移的 README 句子则表明承诺中的消费方从未出现。按照 AGENTS.md 所述“Agent Note 是提案,而非绝对真理”,代码后来证明提案中的这些部分超出了需要;未来的观察者应根据真实消费方的形状,重新引入它实际消费的最小信号或查询。 ## 验证 diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml index b2361b71d4..3e67070db5 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-fold-stdio-ui-helper.md: 01165df19942e8e0adf84ede3a371f53ad12e464 -2026-07-04-fold-stdio-ui-helper.zh.md: 5efaf47694092b4ba02208330dccf6b9bb086fca +2026-07-04-fold-stdio-ui-helper.zh.md: 37b316283e96d7640e1e7af5fdbb05ab487aa6fa diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md index 5efaf47694..37b316283e 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md @@ -14,7 +14,7 @@ readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/ ## 决策 -当时,该辅助函数移入 `@deepseek-ai/dsh-stdio`,成为终端通道插件。`createStdioChat`、其 `StdioRuntime` 测试接缝和单元测试随之一同迁移,使 EOF 处理、渲染、释放以及管道/TTY 行为继续受逐文件覆盖率门禁约束,而不会劫持进程全局量。该模块保留应用挂载所消费的具名 `name`/`inject`/`Config`/`apply` 导出形状;当时的 Echo 和 REPL Loader 冒烟证明组合树,插件形状套件则固定显式 `unwrapExports` 行为。上方取代本文的移除记录负责当前包和示例状态。 +当时,该辅助函数移入 `@deepseek-ai/dsh-stdio`,成为终端通道插件。`createStdioChat`、其 `StdioRuntime` 测试 seam 和单元测试随之一同迁移,使 EOF 处理、渲染、释放以及管道/TTY 行为继续受逐文件覆盖率门禁约束,而不会劫持进程全局量。该模块保留应用挂载所消费的具名 `name`/`inject`/`Config`/`apply` 导出形状;当时的 Echo 和 REPL Loader 冒烟证明组合树,插件形状套件则固定显式 `unwrapExports` 行为。上方取代本文的移除记录负责当前包和示例状态。 早期的支持辅助包已移除:其清单、tsconfig 引用、模块图行和 README 行均已消失,其余文档改为描述包内模块。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml index cd22871392..f11e99d542 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-producerless-vocabulary-variants.md: 34492e6906cd2d795f880310b1bcd120e3953fcf -2026-07-04-prune-producerless-vocabulary-variants.zh.md: c564b3052719cc7e9aef60775a1801f3e214d4cc +2026-07-04-prune-producerless-vocabulary-variants.zh.md: 3cbf89e89828244719bcdf4c7aebeab4eb028ded diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md index c564b30527..3cbf89e898 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md @@ -22,7 +22,7 @@ Status: implemented ### 为什么不保留它们? -[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)曾把“cache hint……有了归属”列为设计后果,预留槽位也确实能表明意图。但空槽位是每个实现和消费者都必须考虑的契约表面(我的 adapter 是否必须遵守 `cache`?我的 renderer 是否必须路由 `agent` 来源?),而相邻 map 自身的 JSDoc 已经拒绝“无 emitter 先预留”——`refusal` 和 `max_turn_requests` 被点名为*首次有内容发出它们时*再添加的变体,而不是提前声明。让已经声明但无用的变体遵守同一标准,才能使词汇真正有意义:只要它位于 map 中,就必须有内容生产它。 +[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)曾把“cache hint……有了归属”列为设计后果,预留槽位也确实能表明意图。但空槽位是每个实现和消费方都必须考虑的契约表面(我的 adapter 是否必须遵守 `cache`?我的 renderer 是否必须路由 `agent` 来源?),而相邻 map 自身的 JSDoc 已经拒绝“无 emitter 先预留”——`refusal` 和 `max_turn_requests` 被点名为*首次有内容发出它们时*再添加的变体,而不是提前声明。让已经声明但无用的变体遵守同一标准,才能使词汇真正有意义:只要它位于 map 中,就必须有内容生产它。 ## 验证 diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml index 7735b3f6d7..00ce291031 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-remove-agent-steering-mirror.md: 9f7cd5abe968ff216cbd7012163ea1c04dc00599 -2026-07-04-remove-agent-steering-mirror.zh.md: 2650dbd142288458afc429fd87c564fed01e1493 +2026-07-04-remove-agent-steering-mirror.zh.md: df4d8af4b552286068d5aa54f25e8e8dfb06df54 diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md index 2650dbd142..df4d8af4b5 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md @@ -10,7 +10,7 @@ Status: implemented `agent/steering` 以相同的 payload 重复了紧接其前的持久事件 `steering/message`。`agent/queued` 仍保留为纯瞬态信号,因为它在持久化之前触发,覆盖了可能在进入日志前被取消的工作。 -Steering 承载真实生产流量——hook bridge 的轮次延续决策通过 `inbox.steer()` 注入其理由,最终成为由 hook 矩阵预期输出固定的持久 `steering/message` 事件——而这些消费者无一例外都观察持久事件。没有任何内容观察镜像。 +Steering 承载真实生产流量——hook bridge 的轮次延续决策通过 `inbox.steer()` 注入其理由,最终成为由 hook 矩阵预期输出固定的持久 `steering/message` 事件——而这些消费方无一例外都观察持久事件。没有任何内容观察镜像。 ## 决策 @@ -22,7 +22,7 @@ Steering 承载真实生产流量——hook bridge 的轮次延续决策通过 ` ### 为什么不保留? -“它是控制信号,不是边界”——但该分类的实际区分是镜像/仅实时,而非控制/边界,并且该事件确实是镜像。希望在入队时收到通知的消费者可以使用 `agent/queued`(及其 steering 标记);希望在排空时收到通知的消费者,本质上是在要求获知 `steering/message` 被追加的时刻,而 `session/event` 会交付相同 payload 并附带持久性。遭拒绝的[退役轮次中途 steering Agent Note](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md)所捍卫的是 steering *功能*——`steer()`、持久事件、强制延续——本次移除不会触及其中任何一项。 +“它是控制信号,不是边界”——但该分类的实际区分是镜像/仅实时,而非控制/边界,并且该事件确实是镜像。希望在入队时收到通知的消费方可以使用 `agent/queued`(及其 steering 标记);希望在排空时收到通知的消费方,本质上是在要求获知 `steering/message` 被追加的时刻,而 `session/event` 会交付相同 payload 并附带持久性。遭拒绝的[退役轮次中途 steering Agent Note](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md)所捍卫的是 steering *功能*——`steer()`、持久事件、强制延续——本次移除不会触及其中任何一项。 ## 验证 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml index a438839d3a..834c3ccf6c 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-tighten-hook-protocol-contract.md: a1972ee8ef486982268ba8886b2413f3557061b4 -2026-07-04-tighten-hook-protocol-contract.zh.md: 51d11b4c2d79bd79b608ea5aa49b677653c3ac41 +2026-07-04-tighten-hook-protocol-contract.zh.md: 891bab33dc2b7557b0024a7b2013894452c38605 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md index 51d11b4c2d..891bab33dc 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -`dsh-hook-protocol`/bridge 契约中有四部分没有遵守 [subagent observe/enrich Agent Note(agent 决策记录)](../feature/2026-06-30-subagent-observe-enrich.md)记下的准则——后者因缺少消费者而删除 `agentType` 生命周期字段,以下各项没有通过同一检验: +`dsh-hook-protocol`/bridge 契约中有四部分没有遵守 [subagent observe/enrich Agent Note(agent 决策记录)](../feature/2026-06-30-subagent-observe-enrich.md)记下的准则——后者因缺少消费方而删除 `agentType` 生命周期字段,以下各项没有通过同一检验: -1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有生产者——bridge 会标记 `'claude'` 和 `'codex'`;所有位置中唯一构造 `'native'` 的是该库自己的单元测试。字段自身的 JSDoc 将 `dialect` 定义为“运行它的 bridge”,而 native 不是 bridge:[拦截接缝 Agent Note](../feature/2026-06-30-interception-seams.md) 记载 native hook 不是一个包,并且“native 插件无需持久 hook 日志即可使用类型化 Decision”;旗舰 native 插件实践示例恰好断言了这一点(完全没有 `hook/*` 事件)。 +1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有生产者——bridge 会标记 `'claude'` 和 `'codex'`;所有位置中唯一构造 `'native'` 的是该库自己的单元测试。字段自身的 JSDoc 将 `dialect` 定义为“运行它的 bridge”,而 native 不是 bridge:[拦截 seam Agent Note](../feature/2026-06-30-interception-seams.md) 记载 native hook 不是一个包,并且“native 插件无需持久 hook 日志即可使用类型化 Decision”;旗舰 native 插件实践示例恰好断言了这一点(完全没有 `hook/*` 事件)。 2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有 merge fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本无物可抑制:hook stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此 hook 作者设置 `suppressOutput: true` 得到的是无声的空操作,且无任何警告。 3. **`defaultTimeoutMs` 在两个 bridge 配置中以浮动字面量双重默认**——schema 的 `.default(600_000)` 加上一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。*提案最初的补救措施是彻底删除该旋钮,但被 no-hardcoded-tunables 审计所取代:审计保留了该旋钮作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下要修的是字面量的归属地。* 4. **`hook/result` 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。** `summarize()`——stderr 截断规则——在 `packages/hooks/hooks-claude/src/index.ts` 与 `packages/hooks/hooks-codex/src/index.ts` 中逐字节相同;decision 字符串规则 `output.decision ?? (output.continue === false ? 'stop' : 'pass')` 同样如此。然而 `dsh-hook-protocol` 声明了 `hook/result`、在文档中将 `stderrSummary` 描述为「已截断」却不拥有截断逻辑,记录了 decision 值却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享持久化事件的语义就会悄然分叉。 diff --git a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml index fa6148d1e6..6c3e5859e7 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-drop-unconsumed-skill-provider-events.md: b0ed7585882328b6abdcf57974200053d9c26048 -2026-07-12-drop-unconsumed-skill-provider-events.zh.md: 557e7ee2155969530a109280d9324b2525e3144a +2026-07-12-drop-unconsumed-skill-provider-events.zh.md: fd380b2c0421abfc3032b88b550b4a3e8b88bf38 diff --git a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md index 557e7ee215..fd380b2c04 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md @@ -16,7 +16,7 @@ skill 发现按需读取当前的提供方映射表,提供方注册时同步 skill 注册表不再声明和 emit 提供方成员变更事件。提供方的注册与 dispose(资源释放)仍为 effect 所有的直接状态变更,同步使已完成的 catalog 失效;查找与发现按需读取当前提供方映射表。测试通过提供方查找和收集到的输出来观察清理行为,而非依赖生命周期通知。 -生成式事件目录、API 目录和生产者/消费者矩阵均不再包含已删除通知。skill system Agent Note(agent 决策记录)和包文档改为通过其由 effect 直接拥有的状态与 cache 失效契约描述注册。 +生成式事件目录、API 目录和生产者/消费方矩阵均不再包含已删除通知。skill system Agent Note(agent 决策记录)和包文档改为通过其由 effect 直接拥有的状态与 cache 失效契约描述注册。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 2e30b8e040..c25a55cc71 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-acp-snapshot-tests.md: 43900632c4e5e3904c2d0e3b75f2a3fe3b3a50cc -2026-06-19-acp-snapshot-tests.zh.md: 58e9c10bfe5d02179eb31e510d63691b99a0c1da +2026-06-19-acp-snapshot-tests.zh.md: c3a926dfe6ed2c0228cc99a5258cbb35798591f8 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 58e9c10bfe..c3a926dfe6 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -61,7 +61,7 @@ Status: implemented ### 隔离:当前靠归一化,后续可加沙箱 -工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,sandbox executor 可以通过现有[能力接缝](../architecture/2026-06-13-capability-seams.md)替换本地后端。 +工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,sandbox executor 可以通过现有[能力 seam](../architecture/2026-06-13-capability-seams.md)替换本地后端。 ### 回放插件是独立的包 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index c9f3fa8acc..ad4c885ad6 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-subagent-snapshot-replay.md: 4aad8b6ddd3e4f8b8ad5565e10b263953d81ea31 -2026-06-22-subagent-snapshot-replay.zh.md: 77a4302fe3aa529972195f87306b24493bbcdd06 +2026-06-22-subagent-snapshot-replay.zh.md: 2dfcee22463c8bb31e68bfb078915475e2bd7f78 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index 77a4302fe3..2dfcee2246 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -13,7 +13,7 @@ Status: implemented - **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent(智能体)和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent(反之亦然)。 - **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行,在同一个 cwd bucket 下有自己的日志,因此子 agent 的 transcript 被静默丢弃。 -这就是 [subagent 接缝 Agent Note(agent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端(PR2)落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。 +这就是 [subagent seam Agent Note(agent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作:进程内后端(PR2)落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。 ## 决策 diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml index 2b114cbf32..b3d9705b16 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-hook-snapshot-matrix.md: ceb91a70e1f9582cf2a7cb4cec0ba8aaf5699b8e -2026-07-04-hook-snapshot-matrix.zh.md: 649d2dd4b3149a9c78ff47cdaf16d7d38fc24fe3 +2026-07-04-hook-snapshot-matrix.zh.md: 61c0c06569db17e7daec8d75d4af8150a1f9dd52 diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md index 649d2dd4b3..61c0c06569 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——把外部 hook 命令映射到 harness 拦截接缝。它们有深入的单元与覆盖率规格覆盖(每个决策分支、每种 payload dialect,针对 mock 接缝驱动),外加一个受密钥门控的 e2e(`hooks.e2e.ts`,实时 `PreToolUse` 阻止)。但完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 子进程、无需密钥重放已记录 session,并将规范化 ACP(Agent Client Protocol)stdout + 重新持久化日志与已提交预期输出进行 diff 的那张网——只覆盖了一个 hook:Claude `UserPromptSubmit` 阻止(`hook-cc-promptsubmit-block`)。 +hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——把外部 hook 命令映射到 harness 拦截 seam。它们有深入的单元与覆盖率规格覆盖(每个决策分支、每种 payload dialect,针对 mock seam 驱动),外加一个受密钥门控的 e2e(`hooks.e2e.ts`,实时 `PreToolUse` 阻止)。但完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 子进程、无需密钥重放已记录 session,并将规范化 ACP(Agent Client Protocol)stdout + 重新持久化日志与已提交预期输出进行 diff 的那张网——只覆盖了一个 hook:Claude `UserPromptSubmit` 阻止(`hook-cc-promptsubmit-block`)。 这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实 hook 进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个 hook 点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex hook 能端到端触发。 @@ -37,16 +37,16 @@ hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) 在构建矩阵过程中发现,记录于此是因为这些遗漏是决策而非疏忽: -- **`SessionStart` 和 `SubagentStart`** 通过脱离且尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有轮次绑定。由此产生的 `context/message` 会与它应先于的工作(首次模型请求 / 子项的第一个轮次)竞速,并落在不确定的日志位置。记录的预期输出甚至无法在自己的重放中复现——对两者执行 10 次重放稳定性检查,结果均为 10/10 次失败。它们继续留在 bridge 的单元覆盖率中,那里会直接驱动接缝而不存在时序竞速。(如果注入未来改为绑定轮次且具备确定性——`TODO(session-start-gating)` 所指方向——它们就能接受快照测试。) +- **`SessionStart` 和 `SubagentStart`** 通过脱离且尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有轮次绑定。由此产生的 `context/message` 会与它应先于的工作(首次模型请求 / 子项的第一个轮次)竞速,并落在不确定的日志位置。记录的预期输出甚至无法在自己的重放中复现——对两者执行 10 次重放稳定性检查,结果均为 10/10 次失败。它们继续留在 bridge 的单元覆盖率中,那里会直接驱动 seam 而不存在时序竞速。(如果注入未来改为绑定轮次且具备确定性——`TODO(session-start-gating)` 所指方向——它们就能接受快照测试。) - **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递轮次(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无 hook 运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。 因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的 hook 点,涵盖两种方言。 ## 后果 -- 现在,两种 dialect 中每个具有可观察 transcript 的 bridge 接缝映射,都在真实应用的完整 transcript 层受到守护——包括此前完全没有端到端覆盖的 Codex bridge。记录的预期输出捕获模型对遭拒绝/遭阻止/强制继续轮次的真实反应,而手工编写的 transcript 只能猜测这种反应。 +- 现在,两种 dialect 中每个具有可观察 transcript 的 bridge seam 映射,都在真实应用的完整 transcript 层受到守护——包括此前完全没有端到端覆盖的 Codex bridge。记录的预期输出捕获模型对遭拒绝/遭阻止/强制继续轮次的真实反应,而手工编写的 transcript 只能猜测这种反应。 - `UserPromptSubmit` 阻止场景无需密钥即可编写(没有模型轮次);其余场景从已记录 fixture(测试前置数据)无需密钥重放。`pnpm run test:snapshot:record` 从实时 API 重新生成记录式 fixture,并像所有记录场景一样在缺少密钥时自行跳过。 -- 证明会变红的准则仍成立:篡改 hook 配置输出(例如改变拒绝理由)会让相应场景在重放时变红——hook 进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际 hook→接缝→循环路径,而非其 mock。 +- 证明会变红的准则仍成立:篡改 hook 配置输出(例如改变拒绝理由)会让相应场景在重放时变红——hook 进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际 hook→seam→循环路径,而非其 mock。 - `acp-agent` 演示现在加载了一个通常会无操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml index 588d335eee..4c2aa1c60c 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-shared-acp-snapshot-package.md: 3e5a2b12114d535490a17361128862f6d1c09a73 -2026-07-08-shared-acp-snapshot-package.zh.md: 86b3e65c76a1bde593c30d0d1c20010ec7e4d6d5 +2026-07-08-shared-acp-snapshot-package.zh.md: c6b943d93e58c714b52d05d1c9b352e7fb8e2205 diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md index 86b3e65c76..c6b943d93e 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -8,7 +8,7 @@ Status: implemented ACP(Agent Client Protocol)快照层([快照 Agent Note(agent 决策记录)](2026-06-19-acp-snapshot-tests.md))由位于一个示例测试目录内的三个模块构建:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,采集持久化日志)、`snapshot-normalize.ts`(纯预期输出规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(记录/重放模式、stdout 预期输出与日志比较、固定请求头一致性守卫、孤立项/必需文件/单一固定项元测试)。 -第二个希望获得快照覆盖的 ACP 示例——直接消费者是 sandbox/approval 组合——只能复制这些模块,恰好分叉了绝不能漂移的逻辑:记录写回、请求头清理、子 session 采集顺序。spawn/client 胶水也在 `acp.e2e.ts`、`hooks.e2e.ts` 和 harness 中重复三份。文件位置决定了测试严格度:逐文件 100% 覆盖率门禁只测量 `packages/*/*/src`,因此这些机制完全未被测量——正是同一种缺口,曾推动 `dsh-llm-replay` 从 `examples/` 移入 [packages/support](../../../../packages/support/README.md)。此外,harness 的 ACP client 硬编码 `requestPermission → cancelled`,因此 approval 往返——sandbox 组合的主打行为——完全无法在快照层表达。 +第二个希望获得快照覆盖的 ACP 示例——直接消费方是 sandbox/approval 组合——只能复制这些模块,恰好分叉了绝不能漂移的逻辑:记录写回、请求头清理、子 session 采集顺序。spawn/client 胶水也在 `acp.e2e.ts`、`hooks.e2e.ts` 和 harness 中重复三份。文件位置决定了测试严格度:逐文件 100% 覆盖率门禁只测量 `packages/*/*/src`,因此这些机制完全未被测量——正是同一种缺口,曾推动 `dsh-llm-replay` 从 `examples/` 移入 [packages/support](../../../../packages/support/README.md)。此外,harness 的 ACP client 硬编码 `requestPermission → cancelled`,因此 approval 往返——sandbox 组合的主打行为——完全无法在快照层表达。 ## 决策 @@ -28,7 +28,7 @@ ACP(Agent Client Protocol)快照层([快照 Agent Note(agent 决策记 - **在 `examples/` 下建共享模块目录**:代码仍在覆盖率门禁之外,且需要跨示例边界的相对导入,违反包名导入约定;`examples/` 的叶子节点按设计应保持轻薄。 - **`dsh-acp-demo` 的 `/testing` 子路径导出**:将测试基础设施耦合到产品包的对外服务接口与依赖集中;`packages/support/` 的存在正是为了真实但兼容性承诺较低的开发/测试包,`dsh-llm-replay` 是先例,本包与之配套。 - **导出原始测试体函数而非套件工厂**:每个示例将重新拥有 `describe`/`it` 骨架(每套件约 80 行注册样板),却无灵活性收益;工厂使消费方只需一张场景表加一次调用,而导出的纯辅助函数在工厂设计内保留了可单元测试性。 -- **使用可注入 ACP `Client` factory 代替声明式 `permissionAnswers`**——灵活性最大,但会把 SDK client 构造泄漏给每个消费者,并恰好在正在统一的层重新引入逐示例漂移;声明式队列让 `input.json` 保持为唯一脚本表面,并与预期输出规范化兼容。 +- **使用可注入 ACP `Client` factory 代替声明式 `permissionAnswers`**——灵活性最大,但会把 SDK client 构造泄漏给每个消费方,并恰好在正在统一的层重新引入逐示例漂移;声明式队列让 `input.json` 保持为唯一脚本表面,并与预期输出规范化兼容。 - **泛化到 ACP 之外(传输无关的快照 harness)**:不存在第二种传输方式;harness 端到端都是 ACP 形态(SDK 客户端、JSON-RPC 帧、`session/update` 等待器),推测性的抽象将是一个超前于任何消费方的 seam 拆分。 ## 测试 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index 61d489a85c..a7f8ad16a1 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-unimplemented-subagent-vocabulary.md: f99a33163b48735f9634b5bb3dcac5c24eb893f8 -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 15368b0a8abb2980af255827498803b8bb6645f7 +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 9e0b9125a4e98523b6782678a9f6356cd45dc2a7 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index 15368b0a8a..9e0b9125a4 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -1,6 +1,6 @@ # Agent Note: 裁剪未实现的 subagent seam 词汇 -Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`toolFilter`、`sendMessage`/`resume`)是有意保留的接口面:该接缝按设计先于实现声明完整的预期契约,使提供方与消费方沿稳定形状演进,而非针对每项能力重新协商。下方的消费方证据分析记录了决策时的状态。 +Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`toolFilter`、`sendMessage`/`resume`)是有意保留的接口面:该 seam 按设计先于实现声明完整的预期契约,使提供方与消费方沿稳定形状演进,而非针对每项能力重新协商。下方的消费方证据分析记录了决策时的状态。 [English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 From ba40f906dc33b33147ac653dcf294946e007c97f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:45:56 +0800 Subject: [PATCH 033/113] docs(i18n): normalize core terminology --- .agents/notes/README.i18n.yaml | 2 +- .agents/notes/README.zh.md | 2 +- docs/core-data-structures/approval.i18n.yaml | 2 +- docs/core-data-structures/approval.zh.md | 4 ++-- docs/core-data-structures/bash.i18n.yaml | 2 +- docs/core-data-structures/bash.zh.md | 4 ++-- docs/core-data-structures/code-runtime.i18n.yaml | 2 +- docs/core-data-structures/code-runtime.zh.md | 2 +- docs/core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 6 +++--- docs/core-data-structures/core.i18n.yaml | 2 +- docs/core-data-structures/core.zh.md | 12 ++++++------ docs/core-data-structures/filesystem.i18n.yaml | 2 +- docs/core-data-structures/filesystem.zh.md | 2 +- docs/core-data-structures/llm-streaming.i18n.yaml | 2 +- docs/core-data-structures/llm-streaming.zh.md | 14 +++++++------- docs/core-data-structures/persistence.i18n.yaml | 2 +- docs/core-data-structures/persistence.zh.md | 6 +++--- docs/core-data-structures/sandbox.i18n.yaml | 2 +- docs/core-data-structures/sandbox.zh.md | 2 +- docs/core-data-structures/scope.i18n.yaml | 2 +- docs/core-data-structures/scope.zh.md | 2 +- docs/core-data-structures/session-query.i18n.yaml | 2 +- docs/core-data-structures/session-query.zh.md | 2 +- docs/core-data-structures/session.i18n.yaml | 2 +- docs/core-data-structures/session.zh.md | 6 +++--- docs/core-data-structures/skills.i18n.yaml | 2 +- docs/core-data-structures/skills.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 2 +- docs/core-data-structures/subagent.zh.md | 4 ++-- docs/core-data-structures/system-prompt.i18n.yaml | 2 +- docs/core-data-structures/system-prompt.zh.md | 2 +- docs/core-data-structures/tools.i18n.yaml | 2 +- docs/core-data-structures/tools.zh.md | 6 +++--- .../user-interaction.i18n.yaml | 2 +- docs/core-data-structures/user-interaction.zh.md | 2 +- docs/core-data-structures/web.i18n.yaml | 2 +- docs/core-data-structures/web.zh.md | 2 +- docs/core-data-structures/workflow.i18n.yaml | 2 +- docs/core-data-structures/workflow.zh.md | 2 +- .../0001-acp-default-export-drops-inject.i18n.yaml | 2 +- .../0001-acp-default-export-drops-inject.zh.md | 6 +++--- ...-expression-disabled-filesystem-tools.i18n.yaml | 2 +- ...2-js-expression-disabled-filesystem-tools.zh.md | 2 +- docs/postmortem/README.i18n.yaml | 2 +- docs/postmortem/README.zh.md | 2 +- 46 files changed, 70 insertions(+), 70 deletions(-) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 7344c11eec..fa9c0d9a21 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668 -README.zh.md: b98d54ca64ed6150ddfc9f25c98ac64fe9a344f0 +README.zh.md: 60ec5421e7f271460daebc966aa6548f6ef8a511 diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index b98d54ca64..60ec5421e7 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -37,7 +37,7 @@ ## 何时需要写一份 -每个非平凡变更都必须在同一 PR 中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 +每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 diff --git a/docs/core-data-structures/approval.i18n.yaml b/docs/core-data-structures/approval.i18n.yaml index 583f1cd542..c9f6b1600c 100644 --- a/docs/core-data-structures/approval.i18n.yaml +++ b/docs/core-data-structures/approval.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write approval.md: c9b411e38508fc7e8ac2e2dd923d9c8cc2f475a3 -approval.zh.md: 54985cd32f6b16ef90a1ddd1f6da4b87a261788d +approval.zh.md: c3d67b195a0c04186911285ccb76cd89eceb421b diff --git a/docs/core-data-structures/approval.zh.md b/docs/core-data-structures/approval.zh.md index 54985cd32f..c3d67b195a 100644 --- a/docs/core-data-structures/approval.zh.md +++ b/docs/core-data-structures/approval.zh.md @@ -8,7 +8,7 @@ ## 标识与结果 -每个请求都会获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent/会话 id 互换。 +每个请求都会获得一个全新的 `ApprovalRequestId`。该品牌类型将 `approval/asked` 与 `approval/decided` 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent(智能体)/会话 id 互换。 ```ts type-equiv /** @@ -48,7 +48,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' type ApprovalPolicy = 'ask' | 'never' ``` -提示词段落会声明 `never` 的确定性行为,并以服务自有的标记记录当前策略。重启后,步骤前叙述器从已记录的请求头中读取该标记,而非从部署 persona 行文中推断状态。ACP 空闲切换会在 bridge 中保持,直到下一个 `turn/start`,因为审批审计和策略事件必须保持在轮次内,以确保持久回放的正确性。 +提示词段落会声明 `never` 的确定性行为,并以服务自有的标记记录当前策略。重启后,步骤前叙述器从已记录的请求头中读取该标记,而非从部署 persona 行文中推断状态。ACP(Agent Client Protocol)空闲切换会在 bridge 中保持,直到下一个 `turn/start`,因为审批审计和策略事件必须保持在轮次内,以确保持久回放的正确性。 ## 审批请求 diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 55ea9b35e9..98855cdc0c 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write bash.md: 35cf2061588907dde41123efb01e453eb9cc929d -bash.zh.md: 2b388bd51047219ed46faa07313bb84da1dbb080 +bash.zh.md: 0cfeb9e1a858f7057e720215c41a757588751122 diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 2b388bd510..0cfeb9e1a8 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -22,7 +22,7 @@ type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>> ## 请求与规格:`resolve()` 拆分 -该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs`/`stdoutMaxBytes` 可选,由配置或请求策略补全)与执行器实际使用的**完全解析后的 spec**(这些字段均为必填)分开。工具层在二者之间调用 `ctx.bash.resolve(request)`——这具体落实了仓库的「包 seam 上显式优于隐式」规则:`BashExecSpec` 的读者不必猜测工作目录或输出预算来自何处。 +该 seam 将**面向模型/插件的请求**(`workdir`/`timeoutMs`/`stdoutMaxBytes` 可选,由配置或请求策略补全)与执行器实际使用的**完全解析后的 spec**(这些字段均为必填)分开。工具层在二者之间调用 `ctx.bash.resolve(request)`——这具体落实了仓库的「包(package) seam 上显式优于隐式」规则:`BashExecSpec` 的读者不必猜测工作目录或输出预算来自何处。 ```ts type-equiv /** @@ -238,4 +238,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose 后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose(资源释放)后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index 21bf3ee3e0..f534e6a821 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write code-runtime.md: a984c0f6422defc879086ff95eb94048aaa6e285 -code-runtime.zh.md: c3989fa10814139fefe91e99d9b050c1e851f8ed +code-runtime.zh.md: 95287f2917c8fd409944569a2f5c3e8417a18efe diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index c3989fa108..95287f2917 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -2,7 +2,7 @@ [English](code-runtime.md) | 中文 -代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)针对宿主提供的异步 binding 运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此而非 [core.md](core.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread 后端和工具注册表消费方(Code Mode)由 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定。 +代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)针对宿主提供的异步 binding 运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread 后端和工具注册表消费方(Code Mode)由 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定。 源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 17801a0c9c..9674987307 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: 3a49976c34b99d583647ca6a20a875faf319ff4a +compaction.zh.md: ccac8590ceb2683bd5f55681288bc433d84f2657 diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 3a49976c34..ccac8590ce 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -1,8 +1,8 @@ -# 上下文压缩 +# 上下文压缩(context compaction) [English](compaction.md) | 中文 -压缩 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md),与 bash 一样分为接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(例如 [dsh-compact-basic](../../packages/compact/compact-basic) 后端)和消费方(延期实现的 `/compact` 工具)。压缩是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此而非 [core.md](core.md) 中。基于 tokenizer 或模板的后端是实现同一接口的兄弟包。与 bash 不同,该接口必然依赖 `dsh-session` 和 `dsh-llm`:其动词作用于 agent 所有的 `Session`,而其持久摘要事件使用 `ContentBlock` 词汇(见[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md))。 +压缩 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md),与 bash 一样分为接口([dsh-compact](../../packages/compact/compact),`ctx.compact`)、实现(例如 [dsh-compact-basic](../../packages/compact/compact-basic) 后端)和消费方(延期实现的 `/compact` 工具)。压缩是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。基于 tokenizer 或模板的后端是实现同一接口的兄弟包(package)。与 bash 不同,该接口必然依赖 `dsh-session` 和 `dsh-llm`:其动词作用于 agent 所有的 `Session`,而其持久摘要事件使用 `ContentBlock` 词汇(见[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md))。 源码:[`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering 已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering(中途引导)已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与孤立结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 6eddadba2b..32019f6002 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write core.md: 7d0f9503dfd4c47f185d2bcef84528145d66fa70 -core.zh.md: dd9219ee682a770917e68e87e49d06df09e54456 +core.zh.md: 21f417bdf8ddb6c4d629de037d95a0196961aeb3 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index dd9219ee68..21f417bdf8 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -36,7 +36,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | | [filesystem.md](filesystem.md) | 文件系统 seam:`FsTarget`、读/写/编辑结果、观测到的文件状态、`FsErrorCode` | | [lsp.md](lsp.md) | LSP 导航 seam:`LspQueryRequest`/`Result`、`LspProvider`/`Service`、四种操作、`LspError` | -| [skills.md](skills.md) | skill 服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | +| [skills.md](skills.md) | skill(技能)服务:发现优先级、`SkillSummary`/`SkillDefinition`、会话前缀目录、面向模型的 `skill` 加载 | | [compaction.md](compaction.md) | 压缩(compaction)seam:`compact/*` 会话事件、`CompactionResult`、`CompactService` 接口 | | [subagent.md](subagent.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | | [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | @@ -47,7 +47,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 ## `…Map → derived-union` 模式 -harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 +harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包(package)。 ```ts ignore-check // The pattern, schematically: @@ -68,7 +68,7 @@ declare module '@deepseek-ai/dsh-llm' { 六个规范 map 使用此模式;插件作者扩展它们: -| Map | 包(package) | 派生 | 目录 | +| Map | 包 | 派生 | 目录 | |---|---|---|---| | `ContentBlockMap` | dsh-llm | `ContentBlock` | [下文](#content-blocks-and-messages) | | `MessageSourceMap` | dsh-llm | `MessageSource` | [下文](#content-blocks-and-messages) | @@ -175,7 +175,7 @@ interface MessageSourceMap { 源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) -提供方与模型发现使用小型、提供方中立的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 +提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 ```ts type-equiv /** Display metadata for one registered provider route. */ @@ -381,7 +381,7 @@ interface SendOptions { } ``` -`InjectOptions` 接受普通消息归属信息和对模型隐藏的持久 JSON 元数据。附加上下文只属于排队输入或 steering 输入,因此合成注入不接受这类上下文: +`InjectOptions` 接受普通消息归属信息和对模型隐藏的持久 JSON 元数据。附加上下文只属于排队输入或 steering(中途引导)输入,因此合成注入不接受这类上下文: ```ts type-equiv /** Options specific to durable synthetic context injection. */ @@ -458,7 +458,7 @@ interface Agent { cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`;该值在发布 `turn/end` 前退役。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall 契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 +[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 ## 发起 Agent diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 4f17d4c4ed..6713615776 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write filesystem.md: 3c9b041da92e71cd20429e8b1549c0b8e6f2436d -filesystem.zh.md: 04d7c63da151f7198099486106ce2fb9d0dd1520 +filesystem.zh.md: 612b90427c6813823d38515257acfdfafcc2ba1e diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 04d7c63da1..612b90427c 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -183,7 +183,7 @@ interface FsEditOutcome { ## 执行上下文(策略插件) -策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 +策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包(package)。 ```ts type-equiv /** diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 11fc83975d..28191bd56c 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb -llm-streaming.zh.md: 9942b571073b2c04c7f38291c133e1fd19de4dd0 +llm-streaming.zh.md: 740fa1f796088e63d0195cfbecf975adb236381c diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 9942b57107..740fa1f796 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -1,4 +1,4 @@ -# LLM 流式输出 +# LLM(大语言模型)流式输出 [English](llm-streaming.md) | 中文 @@ -35,7 +35,7 @@ type StreamChunk = ## `LlmFailure` -每个抛出的失败或最终适配器的带内失败都会规范化为一种可序列化、提供方中立的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 +每个抛出的失败或最终适配器的带内失败都会规范化为一种可序列化、提供方无关的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 ```ts type-equiv /** Serializable provider-boundary facts; policy decides whether they are retryable. */ @@ -59,18 +59,18 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop 关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop(智能体循环)关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 -- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方中立的内容与 provenance,不会收到私有状态。 +- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 -该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 +该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(手写 fetch/SSE(Server-Sent Events))和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 ## `AppIdentity`:应用归属 -每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包 manifest 获取版本;每个字段都是公开产品事实——不含 secret、路径、会话 id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包(package) manifest(元数据清单)获取版本;每个字段都是公开产品事实——不含 secret、路径、会话 id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 ```ts type-equiv /** @@ -156,7 +156,7 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单独的 `resolveModelContext()` 查询会暴露确切路由上对正确性敏感的容量信息,但不会让目录成员关系具有权威性;缺失表示元数据未知,而不是路由无效。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 ```ts public-api /** diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 14b478c66f..98316924b3 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write persistence.md: d4bb4ed1b65eb74ff03483bf3a71a1736300834e -persistence.zh.md: 3000336af79762f1a40e887cad7d69fb4c771e8a +persistence.zh.md: 2e63ff9af2b561a70640fe90ded5faac0a900164 diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 3000336af7..2e63ff9af2 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -8,15 +8,15 @@ ## flush 检查点 -`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通轮次的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose 仍会将其排空。成功 flush 会把已关闭轮次作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭轮次之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 +`session/event` 是一个*同步*通知;持久化插件会将其缓冲(write-behind)至 `session/flush`。循环会 await 普通轮次的检查点后再领取下一个队列项;同步的 idle `inject()` 会调度自己的检查点而不阻塞 `send()`,dispose(资源释放)仍会将其排空。成功 flush 会把已关闭轮次作为一个单元持久提交;被拒绝的 flush 通过 `agent/error` 与 logger 报告——绝不会作为已关闭轮次之后的会话事件——而后端会保留已缓冲事件供下次 flush 使用。 ## 崩溃恢复保留被中断的轮次 后端重新加载一个在轮次中途崩溃的日志时,会发现一个已打开的 `turn/start` 却没有 `turn/end`。它**不会**截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 `turn/end { reason: { kind: 'interrupted' } }` 关闭这个遗留轮次,保持日志平衡与轮次闭合不变式。`interrupted` 是唯一一个不由循环发出的 `TurnEndReason`(见 [session.md](session.md#why-a-turn-ended-turnendreasonmap))。 -## `SessionLocation`——可选的逐会话制品目标 +## `SessionLocation`——可选的逐会话产物目标 -`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立制品,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 +`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径;SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 ```ts type-equiv /** diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 675b9bfa10..3ef2f7cf69 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: 8db5520a125141cd043323ded4d26b92b81d1ea0 +sandbox.zh.md: bd33ffa4cacff22fbd7e0f24648a034310e7cded diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 8db5520a12..bd33ffa4ca 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent 时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 ```ts type-equiv /** diff --git a/docs/core-data-structures/scope.i18n.yaml b/docs/core-data-structures/scope.i18n.yaml index 488c1f88f1..b565e11461 100644 --- a/docs/core-data-structures/scope.i18n.yaml +++ b/docs/core-data-structures/scope.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write scope.md: 73a697f2843293daffff85dabf4656346f7dcd04 -scope.zh.md: 5a40a3e964d2113752efba92beea4f4f333e94b3 +scope.zh.md: f3c591da2befdcff69d89ad0667653392111fb8e diff --git a/docs/core-data-structures/scope.zh.md b/docs/core-data-structures/scope.zh.md index 5a40a3e964..f3c591da2b 100644 --- a/docs/core-data-structures/scope.zh.md +++ b/docs/core-data-structures/scope.zh.md @@ -2,7 +2,7 @@ [English](scope.md) | 中文 -[scope 包](../../packages/core/scope)提供 identity、carrier 与 scoped-layer 词汇,使同一个注册上下文同时代表逐 agent 可见性和共享生命周期所有权。它是库原语,而不是 Cordis 服务;生命周期设计理由由 [agent-scope 运行时设计 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer)规定,注册表层决策由[共享存储 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)规定,可调用 API 与过滤语义则由包 [README](../../packages/core/scope/README.md)规定。 +[scope 包(package)](../../packages/core/scope)提供 identity、carrier 与 scoped-layer 词汇,使同一个注册上下文同时代表逐 agent(智能体)可见性和共享生命周期所有权。它是库原语,而不是 Cordis 服务;生命周期设计理由由 [agent-scope 运行时设计 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer)规定,注册表层决策由[共享存储 Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)规定,可调用 API 与过滤语义则由包 [README](../../packages/core/scope/README.md)规定。 源码:[`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) 与 [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts)。 diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index 7efa74ce1b..aab65db6d9 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session-query.md: 27d0dccc6cf32b4dafebeaf64a3b8aefa138b98d -session-query.zh.md: 1c3abafb009cfc7376c96da4552b49959c09fef3 +session-query.zh.md: a10bb9c4bdd432fecefe2bd978dcd2ea631c3c0a diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 1c3abafb00..a10bb9c4bd 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -2,7 +2,7 @@ [English](session-query.md) | 中文 -对优先使用 live 数据的逻辑会话集合执行精确读取与关系追踪。[包契约](../../packages/session-query/session-query)拥有来源优先级、动态可选持久化、克隆、surface 分类、有界窗口、追踪校验与类型化失败。全文搜索属于另一个拟议的 SQLite 包。 +对优先使用 live 数据的逻辑会话集合执行精确读取与关系追踪。[包(package)契约](../../packages/session-query/session-query)拥有来源优先级、动态可选持久化、克隆、surface 分类、有界窗口、追踪校验与类型化失败。全文搜索属于另一个拟议的 SQLite 包。 源码:[`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 09662ed32a..8dbb8a8ae2 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session.md: e07e38037e51c74886db88d24353266f6035b15a -session.zh.md: 82ddaa2f53585f41fd9a343c9f710b339b2399fd +session.zh.md: 8d3de643a42bdd173528b024276d0f83e90d2d11 diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 82ddaa2f53..8d3de643a4 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -112,7 +112,7 @@ interface SessionEventMap { ### `OutOfBandSessionEventMap`:受限的带外追加显式准入 -仅属于 `SessionEventMap` 并不表示事件可以脱离 agent loop 的常规生命周期追加。事件所有方必须通过声明合并将同一键加入这个空标记映射,`ctx.sessions.appendOutOfBand()` 才会接受该事件;派生类型还会排除所有 surface 事件。被接受的更新会并入已打开的轮次;如果没有打开的轮次,系统则为它创建一个边界配平且已刷新完成的零步骤轮次。 +仅属于 `SessionEventMap` 并不表示事件可以脱离 agent loop(智能体循环)的常规生命周期追加。事件所有方必须通过声明合并将同一键加入这个空标记映射,`ctx.sessions.appendOutOfBand()` 才会接受该事件;派生类型还会排除所有 surface 事件。被接受的更新会并入已打开的轮次;如果没有打开的轮次,系统则为它创建一个边界配平且已刷新完成的零步骤轮次。 ```ts type-equiv /** @@ -176,7 +176,7 @@ interface EpochHeader { } ``` -规范形式:空系统提示词、空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop(智能体循环)实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 +规范形式:空系统提示词、空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop 实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 ## `SessionEvent<T>`:一条日志条目 @@ -530,7 +530,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止(ACP 桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止(ACP(Agent Client Protocol)桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 ## 轮次封闭不变式 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index e71bfaa45c..2f67387224 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 -skills.zh.md: ef67f06b69ac321d1d8bea4ef962a9ac4f6be2dd +skills.zh.md: 0eb4c0aa69ed56117c7508358c0d47e3b3e95fcb diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index ef67f06b69..0eb4c0aa69 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,7 +2,7 @@ [English](skills.md) | 中文 -[skill 能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。skill(技能)是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 2dcc46507e..e0cd108b13 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write subagent.md: 97d6862c10a0757c41472f207f857c25f3f5d50f -subagent.zh.md: de0e5c89d508565b84ad1240a11f069dc3795e9c +subagent.zh.md: d28e18df6361c4a87a66b027f20cf584b8b14ecb diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index de0e5c89d5..d28e18df63 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -2,9 +2,9 @@ [English](subagent.md) | 中文 -subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM 适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 +subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 -接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为兄弟包(`dsh-subagent-spawn`、`-fork`、`-acp`);面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 +接口:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。实现为三个兄弟包(package):`dsh-subagent-spawn`、`-fork`、`-acp`;面向模型的消费方是 [dsh-tool-subagent](../../packages/subagent/tool-subagent)。提案与设计理由见 [subagent Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) diff --git a/docs/core-data-structures/system-prompt.i18n.yaml b/docs/core-data-structures/system-prompt.i18n.yaml index 15f1076fea..e697ec6bcd 100644 --- a/docs/core-data-structures/system-prompt.i18n.yaml +++ b/docs/core-data-structures/system-prompt.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write system-prompt.md: 63a750c74300b4353f132d3dae9da52a10631f23 -system-prompt.zh.md: e04510dbf8b97d01a568ffbd878de691be267272 +system-prompt.zh.md: 3f7ab9aee5743616f00e95e81fb9a3a4af2c6e8a diff --git a/docs/core-data-structures/system-prompt.zh.md b/docs/core-data-structures/system-prompt.zh.md index e04510dbf8..3f7ab9aee5 100644 --- a/docs/core-data-structures/system-prompt.zh.md +++ b/docs/core-data-structures/system-prompt.zh.md @@ -2,7 +2,7 @@ [English](system-prompt.md) | 中文 -[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 +[system-prompt 包(package)](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index bc9fe6c5f3..4f6dd0c8d6 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write tools.md: ce14a37da33f89b8b90d6d8e70756f94e3d690dd -tools.zh.md: c85ebe4d77d60e83eadb91aa5dbddd6009913614 +tools.zh.md: 77b1c9534c5eae8843e458eedb6af0e8950375ae diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index c85ebe4d77..77b1c9534c 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -310,7 +310,7 @@ type PostToolDecision = 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 -后置策略可以替换内容;块会变为包含纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 +后置策略可以替换内容;阻止决策会变为包含纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 ## 结构化输出 schema 子集 @@ -365,11 +365,11 @@ type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } ## 工具展示 UI 词汇 -工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI 日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: +工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 - `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,无能力的 UI 获得桥接层从 `output` 派生的围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff——例如文件创建。`tool_call_update` 的内容会替换调用的内容,因此变更工具即使与调用时的片段重复也要返回此卡片,以防结果文本覆盖 diff)。 -`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;ACP 桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并根据会话 cwd 将文件卡片标题转换为相对路径。 +`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;ACP(Agent Client Protocol)桥接层将 `diff` 卡片映射为 `{ type: 'diff' }` 内容块,将 `terminal` 卡片映射为 `_meta` 终端约定,并根据会话 cwd 将文件卡片标题转换为相对路径。 完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。`bash` schema 与执行器见 [bash.md](bash.md);通用后台控制见 [tasks.md](tasks.md)。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 7a135fdf88..7f2ce7042c 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write user-interaction.md: c7684879c6b81d75e2737857279e68e30626bfa4 -user-interaction.zh.md: e1772fdd9427829027e108b38093c6c04c23f041 +user-interaction.zh.md: 12ff3310e846ad5a09fba414985064865407c317 diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index e1772fdd94..12ff3310e8 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -2,7 +2,7 @@ [English](user-interaction.md) | 中文 -[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent 才能继续时所使用的、提供方中立的词汇。UI surface 提供活跃的 `UserInteractionProvider`:`dsh-tui` 使用键盘驱动的 overlay,`dsh-acp` 则把问题映射为 ACP 表单 elicitation。 +[dsh-user-interaction](../../packages/ui/user-interaction) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。UI surface 提供活跃的 `UserInteractionProvider`:`dsh-tui` 使用键盘驱动的 overlay,`dsh-acp` 则把问题映射为 ACP(Agent Client Protocol)表单 elicitation。 源码:[`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml index 0eba5d9be2..f2552bd442 100644 --- a/docs/core-data-structures/web.i18n.yaml +++ b/docs/core-data-structures/web.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write web.md: 20d07240c9d9fea2f1f5abbac810f349a3e81f9b -web.zh.md: ab786f83457c16202f879be58ff55885523f874b +web.zh.md: f67b0f576315809109eecb545c0501e93bac9fd3 diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md index ab786f8345..f67b0f5763 100644 --- a/docs/core-data-structures/web.zh.md +++ b/docs/core-data-structures/web.zh.md @@ -2,7 +2,7 @@ [English](web.md) | 中文 -Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),在同一个 `ctx.web` 服务上横跨**两项能力**(search 与 fetch),并拆分到多个包:接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local))与消费方([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop 主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换 search 提供方不会改变模型请求 query 的方式,更换 fetch 实现也不会改变模型请求 URL 的方式。 +Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md),在同一个 `ctx.web` 服务上横跨**两项能力**(search 与 fetch),并拆分到多个包(package):接口([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、实现([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-fetch-local](../../packages/web/web-fetch-local))与消费方([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。更换 search 提供方不会改变模型请求 query 的方式,更换 fetch 实现也不会改变模型请求 URL 的方式。 源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/docs/core-data-structures/workflow.i18n.yaml b/docs/core-data-structures/workflow.i18n.yaml index fecf373167..492a9bea08 100644 --- a/docs/core-data-structures/workflow.i18n.yaml +++ b/docs/core-data-structures/workflow.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write workflow.md: 8d271b89e71de6f6bef548aa8da61402ef9ada6e -workflow.zh.md: f603a50fc33fb9ab4260e8281ee04e892f677255 +workflow.zh.md: b8ed699eb52d9f0cef23c513f625de7e82c46c45 diff --git a/docs/core-data-structures/workflow.zh.md b/docs/core-data-structures/workflow.zh.md index f603a50fc3..b8ed699eb5 100644 --- a/docs/core-data-structures/workflow.zh.md +++ b/docs/core-data-structures/workflow.zh.md @@ -102,7 +102,7 @@ interface WorkflowResult { ## 活跃运行:`WorkflowRun` -脚本执行期间消费方持有的句柄。消费方 await `result`,可中途 `cancel`,且必须在每条路径上 `dispose`。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后卡死。`dispose()` = cancel + 有界 settle + 等待子 agent 停稳;它不会因脚本卡死而挂起。 +脚本执行期间消费方持有的句柄。消费方 await `result`,可中途 `cancel`,且必须在每条路径上 `dispose`(资源释放)。`result` 不会 reject:脚本失败以 `stopReason: 'error'` resolve;一旦运行被取消,即使脚本本身永不 settle,它也会在引擎的有界宽限期内 settle(引擎强制以 `cancelled` settle;worker-thread 引擎随后终止脚本的 worker),因此消费方 await `result` 不会在取消后卡死。`dispose()` = cancel + 有界 settle + 等待子 agent 停稳;它不会因脚本卡死而挂起。 ```ts type-equiv /** diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index 58f4bb356e..d48af64198 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0001-acp-default-export-drops-inject.md: ab3efc880cb5290dc149b6bacb276ccf581968c1 -0001-acp-default-export-drops-inject.zh.md: 38bbecb4444eef1a7cdc4f48235bb83d597ce1e5 +0001-acp-default-export-drops-inject.zh.md: 763b2e6230cf42dcd6985df3d7e2da8766176fd5 diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index 38bbecb444..763b2e6230 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -1,12 +1,12 @@ -# 事故复盘(postmortem) 0001:ACP 服务器在连接时崩溃——`export default` 丢弃了插件的 `inject` +# 事故复盘(postmortem) 0001:ACP(Agent Client Protocol)服务器在连接时崩溃——`export default` 丢弃了插件的 `inject` [English](0001-acp-default-export-drops-inject.md) | 中文 -Status: resolved (fix in PR #41 `feat/acp-2-bridge`) +Status: resolved (fix in PR(Pull Request) #41 `feat/acp-2-bridge`) ## 摘要 -两个集成错误在单元测试全覆盖的情况下仍然导致 ACP(Agent Client Protocol)崩溃:一个 default export 使 Loader 丢弃了 `inject`,一个经 traceable 代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复方案增加了无需 API key 的真实 Loader 覆盖率,并为插件导出和可选服务访问制定了包(package)级规则。 +两个集成错误在单元测试全覆盖的情况下仍然导致 ACP 崩溃:一个 default export 使 Loader 丢弃了 `inject`,一个经 traceable 代理的可选服务查找在 shadow 边界上失败。手动挂载的测试绕过了这两条路径。修复方案增加了无需 API key 的真实 Loader 覆盖率,并为插件导出和可选服务访问制定了包(package)级规则。 ## 概述 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index 19802cabab..83106b4794 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: 7b7c34bb77a141eaef383fd9a655caff2a484ce1 +0002-js-expression-disabled-filesystem-tools.zh.md: 440c0642497930cf50cd799bf77af4460e29c2da diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 7b7c34bb77..440c064249 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -22,7 +22,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 时间线 -- PR #261 整合了 ACP 组合并刷新了文件系统快照,同时引入了条件式文件系统入口。 +- PR(Pull Request) #261 整合了 ACP 组合并刷新了文件系统快照,同时引入了条件式文件系统入口。 - 所有单元测试、覆盖率、快照、文档、构建和 hygiene 检查均通过。 - 对刷新后的文件系统预期输出的评审发现了通用的失败卡片和结构化的 `UNKNOWN_TOOL` 结果。 - 一次真实的 Loader 启动确认:每个 `disabled` 值仍为表达式对象,每个文件系统 fiber 均未创建。 diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index 6673537b87..e68d3a1a07 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: df0e2fcb8540aeed005153dbecc451d781ca5ff1 -README.zh.md: 1b099919720867dbc8b6766121bff22d3f221c4c +README.zh.md: 2ce6de475c705b02cd9dabfb2181929d81478e2c diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 1b09991972..2ce6de475c 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -12,5 +12,5 @@ | # | 标题 | |---|---| -| [0001](0001-acp-default-export-drops-inject.md) | ACP 服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | +| [0001](0001-acp-default-export-drops-inject.md) | ACP(Agent Client Protocol)服务器在连接时崩溃:`export default` 丢失了插件的 `inject` | | [0002](0002-js-expression-disabled-filesystem-tools.md) | 文件系统快照工具被一个字面量 `!!js` 对象永久禁用 | From 44fd93fd06fff32e83351c77673b25c9af5dfb93 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 19:15:45 +0800 Subject: [PATCH 034/113] =?UTF-8?q?feat(agent):=20unify=20send(target=20?= =?UTF-8?q?=C3=97=20wakeup),=20coalesce=20context/message=20into=20user/me?= =?UTF-8?q?ssage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace send/steer/inject with one Agent.send primitive over the (target × wakeup) matrix; followup/steer/inject become fixed-preset alias methods on the now-abstract Agent class. Coalesce context/message into user/message (injected context is a non-user source). Replace agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel keepInbox, and add a FIFO-conservation invariant. --- ...nified-send-and-coalesced-user-messages.md | 39 ++++ docs/agent-lifecycle.md | 2 +- docs/cordis-catalog/events.md | 116 ++++++--- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 202 ++++++++++++---- docs/core-data-structures/goal.md | 2 +- docs/core-data-structures/session.md | 67 +++--- docs/event-producer-consumer.md | 34 +-- docs/persistence-catalog.md | 70 ++---- docs/tool-catalog.md | 4 +- docs/tool-execution-pipeline.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 6 +- ...ve-mode-switching-2026-07-07.session.jsonl | 8 +- .../goal-session/session.expected.jsonl | 4 +- examples/acp-agent/tests/goal.snapshot.ts | 1 + .../code-mode-workspace-context/session.jsonl | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../hook-cc-posttool-context/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-codex-posttool-context/session.jsonl | 2 +- .../session.jsonl | 2 +- .../permission-switching/session.jsonl | 2 +- .../snapshots/repeat-tool-guard/session.jsonl | 4 +- .../snapshots/workspace-context/session.jsonl | 2 +- .../headless-agent/tests/code-mode.e2e.ts | 3 +- .../headless-agent/tests/headless.snapshot.ts | 2 +- .../goal-tools/stream-json.expected.jsonl | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 12 +- .../client/connection/src/client/fixture.ts | 2 +- .../src/client/sessions/fold-adapter.ts | 15 +- .../client/runtime/tests/fold-adapter.spec.ts | 2 +- .../compact-basic/tests/compact-basic.spec.ts | 7 +- .../compact/tests/tool-pairing.spec.ts | 12 +- .../session-reference/src/projection.ts | 1 - .../tests/session-reference.spec.ts | 2 +- packages/context/time-context/src/index.ts | 5 +- .../context/time-context/src/invariant.ts | 6 +- .../time-context/tests/invariant.spec.ts | 6 +- .../time-context/tests/time-context.e2e.ts | 2 +- .../time-context/tests/time-context.spec.ts | 22 +- .../context/workspace-context/src/state.ts | 4 +- .../tests/workspace-context.e2e.ts | 6 +- .../tests/workspace-context.spec.ts | 36 ++- .../cordis/tool-cordis/src/api-catalog.ts | 58 +++-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 84 ++++--- packages/core/agent-loop/src/inbox.ts | 31 ++- packages/core/agent-loop/src/loop.ts | 15 +- packages/core/agent-loop/tests/agent.spec.ts | 14 +- packages/core/agent-loop/tests/cancel.spec.ts | 19 ++ .../tests/contract-regressions.spec.ts | 36 +-- .../agent-loop/tests/coverage-edges.spec.ts | 2 +- packages/core/agent-loop/tests/inbox.spec.ts | 28 ++- .../agent-loop/tests/interception.spec.ts | 52 ++--- .../core/agent-loop/tests/invariant.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 24 +- .../tests/request-reconstruction.spec.ts | 4 +- .../agent-loop/tests/request-recovery.spec.ts | 11 +- .../core/agent-loop/tests/tool-calls.spec.ts | 13 +- packages/core/agent/README.md | 13 +- packages/core/agent/src/invariant.ts | 21 ++ packages/core/agent/src/types.ts | 220 +++++++++++++----- packages/core/agent/tests/agent.spec.ts | 16 +- packages/core/agent/tests/invariant.spec.ts | 38 +++ .../core/scope/src/scoped-events.generated.ts | 4 +- packages/core/scope/tests/invariant.spec.ts | 4 +- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 5 +- packages/core/session/src/surface.ts | 3 +- packages/core/session/src/types.ts | 59 ++--- .../core/session/tests/derived-cache.spec.ts | 2 +- packages/core/session/tests/session.spec.ts | 12 +- packages/core/session/tests/surface.spec.ts | 7 +- packages/examples/cli-demo/tests/cli.spec.ts | 6 +- .../command-goal/tests/command-goal.spec.ts | 11 +- packages/goal/goal-session/src/index.ts | 4 +- .../goal-session/tests/goal-session.spec.ts | 44 ++-- .../goal/goal-session/tests/invariant.spec.ts | 4 +- packages/goal/goal/src/fold.ts | 53 +++-- packages/goal/goal/src/index.ts | 4 +- packages/goal/goal/src/runtime.ts | 2 +- packages/goal/goal/src/types.ts | 2 +- packages/goal/goal/tests/goal.e2e.ts | 4 +- packages/goal/goal/tests/goal.spec.ts | 37 +-- packages/goal/goal/tests/invariant.spec.ts | 8 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 13 +- .../tests/repeat-tool-guard.spec.ts | 4 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 10 +- .../hooks-claude/tests/coverage-cases.ts | 26 +-- .../hooks/hooks-codex/tests/coverage-cases.ts | 28 +-- packages/plan/plan-mode/src/index.ts | 2 +- .../plan/plan-mode/tests/integration.spec.ts | 9 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 2 +- packages/pty/pty-local/tests/index.spec.ts | 2 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 1 + .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../session-query/tests/tracing.spec.ts | 6 +- .../tests/subagent-inprocess.spec.ts | 2 +- .../tests/subagent-spawn.spec.ts | 4 +- packages/support/invariants/README.md | 2 +- packages/tasks/tasks/tests/tasks.spec.ts | 1 + packages/ui/acp/src/index.ts | 9 +- packages/ui/acp/tests/bridge.spec.ts | 2 +- packages/ui/acp/tests/turns.spec.ts | 4 +- packages/ui/jsonrpc/tests/server.spec.ts | 2 +- packages/ui/tui/src/index.ts | 56 +++-- packages/ui/tui/tests/harness.ts | 4 + .../tui/tests/session-reference.snapshot.ts | 2 +- packages/ui/tui/tests/tui.snapshot.ts | 4 +- packages/ui/tui/tests/tui.spec.ts | 38 +-- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 4 +- scripts/type-equiv.manifest.json | 19 +- 117 files changed, 1249 insertions(+), 728 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md new file mode 100644 index 0000000000..0dfa875d0b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -0,0 +1,39 @@ +# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message + +Status: implemented + +## Problem + +The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. + +Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). + +## Decision + +**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. + +**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. + +**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. + +**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. + +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. + +**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). + +## Alternatives considered + +- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead. +- **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. + +## Consequences + +The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. + +## Related + +- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. +- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. +- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b732708e1f..9a97873a17 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -18,7 +18,7 @@ sequenceDiagram participant Persistence participant SDK as UI or SDK listener User->>Agent: send(content) - Agent-->>SDK: <code>agent/queued</code> + Agent-->>SDK: <code>agent/inbox/enqueue</code> Agent->>Driver: queued work wakes driver Driver-->>SDK: <code>agent/status</code> running Driver->>Session: <code>turn/start</code> diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ca3fd0601..7c9ae4bea8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,72 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/dequeue` — emit + +The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps. Fires after the item leaves its FIFO and before it becomes a durable message. + +```ts cordis-catalog +/** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/discard` — emit + +`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them. Fires once per effective clearing call with every discarded item, after `agent/cancel-requested` and before the abort. + +```ts cordis-catalog +/** + * `cancel()` (without `keepInbox`) dropped pending inbox items without + * delivering them. Fires once per effective clearing call with every + * discarded item, after `agent/cancel-requested` and before the abort. + * @param agent - the agent whose inbox was cleared. + * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/enqueue` — emit + +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `info` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + +```ts cordis-catalog +/** + * A detached, frozen item entered the agent's inbox (queued or steering + * FIFO). Source defaults are already applied, so `info` holds the exact + * accepted values. This is the enqueue-time live signal; the durable record + * is the eventual `user/message`/`steering/message`. Injection + * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * @param agent - the agent whose inbox received the item. + * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -119,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -142,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -169,28 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) - -### `agent/queued` — emit - -Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. - -```ts cordis-catalog -/** - * Detached, frozen content entered the agent's inbox. Source defaults have - * already been applied, so these are the exact values retained for the log. - * @param agent - the agent whose inbox received the message. - * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source, contexts, and whether it entered as steering. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode emit - */ -'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void -``` - -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [HookContext](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -215,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -241,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:442`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -267,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -289,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -309,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -332,7 +376,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -354,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -376,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0fcb2fc28e..ef40314ed8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1173,7 +1173,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6b5596b782..c2d8b56a80 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -323,7 +323,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -351,7 +351,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle @@ -361,10 +361,35 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Message options. An omitted source attests direct human input as `{ kind: 'user' }` - * and may authorize policy consumers, so non-human producers must label their content. + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +type SendTarget = 'next-turn' | 'next-step' +``` + +```ts type-equiv +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. */ interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -372,16 +397,47 @@ interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } ``` -`InjectOptions` accepts ordinary message attribution and durable model-hidden JSON metadata. Attached contexts belong only to queued or steering input, so synthetic injection cannot accept them: +The fixed-preset aliases own `target` and `wakeup`, so they accept only the remaining fields: ```ts type-equiv -/** Options specific to durable synthetic context injection. */ -interface InjectOptions extends Omit<SendOptions, 'contexts'> { - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'> +``` + +The `agent/inbox/*` live events carry the resolved facts of one FIFO item; injection bypasses the FIFOs and never appears on them: + +```ts type-equiv +/** + * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` + * live events. Source defaults are already applied, so these are the exact + * values the item was accepted with. `steering` is true for a `next-step` + * item drained between steps; a `next-turn` item is claimed at a turn boundary. + */ +interface InboxItemInfo { + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} +``` + +```ts type-equiv +/** Options for {@link Agent.cancel}. */ +interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean } ``` @@ -392,59 +448,103 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` +`Agent` is an abstract class: concrete drivers implement the abstract members, while `followup`/`steer`/`inject` are shared concrete delegates to the single abstract `send` over the (`target` × `wakeup`) matrix. + ```ts type-equiv -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ -interface Agent { +/** + * Public agent handle; its concrete implementation is internal to + * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so + * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, + * {@link Agent.inject}) are shared concrete delegates over the single abstract + * {@link Agent.send} primitive; concrete drivers implement `send` once. + */ +abstract class Agent { /** The single identity shared with {@link session}. */ - readonly id: SessionId - readonly options: AgentOptions - readonly session: Session - readonly status: AgentStatus + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context + abstract readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole - * ordinary message in its FIFO-ordered turn; the next claimed item waits for - * that turn's checkpoint. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before notification or enqueue. + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, contexts, and meta. */ - send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): void /** - * Submit steering while the agent is `running`. An open turn records it at - * the next steering checkpoint before a request or continuation decision; - * policy may stop before another step. After turn close and its checkpoint, - * any remainder is queued for a later turn; terminal `agent/turn-stop`, - * cancellation, or disposal may discard it. Uses the same synchronous - * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. - */ - steer(content: ContentBlock[], options?: SendOptions): void - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before turn - * close even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. - */ - inject(content: ContentBlock[], options?: InjectOptions): void - - /** - * Clear all queued and steering work, including items waiting to start, and - * abort the active turn. An effective call first emits - * `agent/cancel-requested` with the resolved typed cause. The first cause wins - * for the active turn, and `whenIdle()` resolves after cancellation reaches - * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op - * and does not arm later work. The active turn snapshots and freezes the cause. + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause?: AgentCancelCause): void + abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - whenIdle(): Promise<void> + abstract whenIdle(): Promise<void> + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + */ + followup(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } + + /** + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + */ + steer(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: false }) + } } ``` @@ -460,7 +560,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes `context/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -470,8 +570,8 @@ interface HookContext { content: ContentBlock[] source: MessageSource /** - * Model placement. Absent or `separate` records an independent - * `context/message`; `prompt-prefix` prepends this context and a stable + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index d45847ba0f..c0351f2f77 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -69,7 +69,7 @@ interface GoalView extends GoalSnapshot { ## Durable changes -Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant. +Every mutation is a round-zero goal-sourced `user/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant. ```ts type-equiv /** Full-snapshot goal mutation retained in a model-visible context event. */ diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 7f61be2e73..d27f6fae88 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -9,7 +9,13 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. ```ts type-equiv -/** Shared payload for ordinary and steering prompt messages. */ +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. `meta` carries durable model-hidden producer state. + */ interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ content: ContentBlock[] @@ -17,6 +23,15 @@ interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope + /** + * Opaque durable JSON state retained on the event but hidden from the model + * projection. It is the intended channel for a future framing directive (a + * producer declares the frame, a dedicated renderer applies it — see the + * deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. + */ + meta?: JsonValue } ``` @@ -46,29 +61,21 @@ interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (the queued message claimed for this turn). */ + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData /** * Durable record of a prompt veto and its reason. It is log-only: the blocked * prompt never enters the model-visible surface, and its turn runs zero steps. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } - /** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - 'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue - } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -189,7 +196,7 @@ A proper discriminated union over `type` (not independent `type`/`data` unions), * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -223,7 +230,7 @@ For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-emp ## Surface types -The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). +The four message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md). ### `SurfaceEventType` — the message-producing subset of event types @@ -237,7 +244,6 @@ type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' ``` @@ -248,7 +254,7 @@ type SurfaceEventType = * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -455,7 +461,7 @@ declare class Session { - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. +- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. @@ -479,11 +485,12 @@ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `context/message` in a one-shot turn - * (`turn/start` → `context/message` → `turn/end`) so every event in the log - * stays turn-enclosed — the durability/replay boundary is the turn, and a - * bare event between turns would otherwise be indistinguishable from a crash - * tail on reload. + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. */ injection: { kind: 'injection'; source: MessageSource } } @@ -532,13 +539,13 @@ interface TurnEndReasonMap { ## The turn-enclosure invariant -Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). +Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `user/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). -The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a04e633377..0825bbf348 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b5a3c60463..c94568f4d3 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -24,14 +24,13 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' /** * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -51,7 +50,7 @@ export type SurfaceOp = * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. @@ -79,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) ### `compact/*` @@ -221,33 +220,6 @@ Types: [ContentBlock](core-data-structures/core.md) Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) -### `context/*` - -#### `context/message` — surface - -```ts persistence-catalog -/** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ -'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue -} -``` - -Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) - -Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) - ### `hook/*` #### `hook/invoked` — log-only @@ -357,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) ### `request/*` @@ -371,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -427,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages 'steering/message': PromptMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) ### `step/*` @@ -438,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -447,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `todo/*` @@ -460,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) ### `tool/*` @@ -477,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -521,7 +493,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `turn/*` @@ -539,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -555,15 +527,23 @@ Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `user/*` #### `user/message` — surface ```ts persistence-catalog -/** A user-visible prompt (the queued message claimed for this turn). */ +/** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData ``` -Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 5c1bfbfdf3..ded9a0973e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,12 +23,12 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | -| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | -| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | +| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 5fc21db2f5..46fd009ada 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -20,7 +20,7 @@ flowchart TD owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>, <code>tool/code-dispatch</code>"] post["<code>tools/post-execute</code> waterfall<br/>accept, block, replace, add context"] final["<code>tools/result</code> synchronous notification<br/>frozen authoritative outcome"] - context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"] + context["Active-batch additionalContexts FIFO<br/>injected user/message after recorded tool results"] toolResult["Session event: <code>tool/result</code><br/>single model-facing outcome"] allResults["Tool batch settled<br/>recorded tool/result events complete"] presentResult["UI completed card<br/>presentResult(args, result)"] diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e35d47cea0..7d014991ed 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -140,11 +140,11 @@ const SCENARIOS: Scenario[] = [ // Keyless, authored (like error-finish/cancel): deterministically forcing a // LIVE model to repeat one call three times is not a stable recording, so // the fixture scripts five identical todo_write calls and pins BOTH reminder - // tiers (gentle at 3, detailed at 5) as context/message in transcript and log. + // tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, // Authored replay: a root AGENTS.md pins the session prefix, then a read in // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing - // context/message. Both AGENTS.md fixtures are symlinks to a sibling + // injected user/message. Both AGENTS.md fixtures are symlinks to a sibling // AGENTS.canonical.md, so this scenario also guards that discovery follows a // symlinked instruction file to its target's content. The scenario-specific // config keeps home/root discovery hermetic, and the resulting prefix needs @@ -220,7 +220,7 @@ const SCENARIOS: Scenario[] = [ // tool/code-dispatch events. Each overlay composes and pins its own header class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, // A nested fs dispatch inside run_code discovers workspace instructions. The - // context/message must follow the outer result while retaining workspace + // injected user/message must follow the outer result while retaining workspace // provenance, which proves Code Mode carries deferred tool context end to end. { name: 'code-mode-workspace-context', diff --git a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl index 10ffb8c507..cb28e3c646 100644 --- a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl +++ b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl @@ -688,7 +688,7 @@ {"type":"turn/start","seq":686,"time":1783421455801,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"approval/policy","seq":687,"time":1783421455801,"data":{"policy":"never"}} {"type":"user/message","seq":688,"time":1783421455801,"data":{"content":[{"type":"text","text":"帮我创建一个 c.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} {"type":"step/start","seq":690,"time":1783421455802,"data":{"turn":4,"step":1}} {"type":"request/header-delta","seq":691,"time":1783421455802,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":["","Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation."]}}} {"type":"assistant/chunk","seq":692,"time":1783421456825,"data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -830,7 +830,7 @@ {"type":"turn/start","seq":828,"time":1783421478599,"data":{"turn":5,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"bash/sandbox-mode","seq":829,"time":1783421478599,"data":{"mode":"workspace-write"}} {"type":"user/message","seq":830,"time":1783421478599,"data":{"content":[{"type":"text","text":"帮我创建一个 d.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} +{"type":"user/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} {"type":"step/start","seq":832,"time":1783421478600,"data":{"turn":5,"step":1}} {"type":"request/header-delta","seq":833,"time":1783421478600,"data":{"system":{"keepStart":12,"keepEnd":2,"insert":["Bash commands run under the \"workspace-write\" file sandbox."]}}} {"type":"assistant/chunk","seq":834,"time":1783421479489,"data":{"turn":5,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -1482,7 +1482,7 @@ {"type":"turn/start","seq":1480,"time":1783421524030,"data":{"turn":7,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"approval/policy","seq":1481,"time":1783421524030,"data":{"policy":"ask"}} {"type":"user/message","seq":1482,"time":1783421524030,"data":{"content":[{"type":"text","text":"帮我在 ~ 创建一个 f.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"} {"type":"step/start","seq":1484,"time":1783421524030,"data":{"turn":7,"step":1}} {"type":"request/header-delta","seq":1485,"time":1783421524030,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":[]}}} {"type":"assistant/chunk","seq":1486,"time":1783421524940,"data":{"turn":7,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -1946,7 +1946,7 @@ {"type":"turn/start","seq":1944,"time":1783421552564,"data":{"turn":9,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"bash/sandbox-mode","seq":1945,"time":1783421552564,"data":{"mode":"danger-full-access"}} {"type":"user/message","seq":1946,"time":1783421552564,"data":{"content":[{"type":"text","text":"创建一个 h.md"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} +{"type":"user/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"} {"type":"step/start","seq":1948,"time":1783421552564,"data":{"turn":9,"step":1}} {"type":"request/header-delta","seq":1949,"time":1783421552564,"data":{"system":{"keepStart":12,"keepEnd":0,"insert":["Bash commands run under the \"danger-full-access\" file sandbox."]}}} {"type":"assistant/chunk","seq":1950,"time":1783421553289,"data":{"turn":9,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index 8954fd6bad..d598b34e37 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} {"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -49,6 +49,6 @@ {"type":"step/start","seq":47,"time":0,"data":{"turn":3,"step":1}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"context/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} {"type":"step/end","seq":51,"time":0,"data":{"turn":3,"step":1}} {"type":"turn/end","seq":52,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index 4bb01acd16..23359f9982 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -83,6 +83,7 @@ describe('ACP same-session goal snapshot', () => { const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) expect(calls).toEqual(['create_goal', 'get_goal']) const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal' + && event.data.source.round > 0 ? [event.data.source.round] : []) expect(rounds).toEqual([1, 2]) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 281970523c..29bca35a09 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -87,7 +87,7 @@ {"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} {"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"<path>./nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}} {"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"<path>/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[85],"surfaceOp":"append"} -{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} {"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index bd13d3b146..5a51f98a42 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit<SendOptions, 'contexts'> {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 6a78b05a65..04f138d06c 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit<SendOptions, 'contexts'> {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index c638389319..910bdfca68 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -63,7 +63,7 @@ {"type":"hook/invoked","seq":61,"time":1783352197968,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":62,"time":1783352197976,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.404540999999881}} {"type":"tool/result","seq":63,"time":1783352197976,"data":{"turn":1,"step":1,"callId":"call_00_HbCMzTslWBZTSphWN0z97382","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} -{"type":"context/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"user/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352197977,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352197977,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352198981,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 46561a4760..921bc270d9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"hook/invoked","seq":1,"time":1783352160546,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352160564,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":17.45639600000004}} {"type":"user/message","seq":3,"time":1783352160564,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1783352160564,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"step/start","seq":6,"time":1783352160565,"data":{"turn":1,"step":1}} {"type":"request/header","seq":7,"time":1783352160566,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index 9b58a769c5..deab3ab423 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -63,7 +63,7 @@ {"type":"hook/invoked","seq":61,"time":1783352229622,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":62,"time":1783352229632,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":9.27664199999981}} {"type":"tool/result","seq":63,"time":1783352229632,"data":{"turn":1,"step":1,"callId":"call_00_Q6wHtakaip2QNfIXaVJY5458","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} -{"type":"context/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"user/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352229633,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352229633,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352230757,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index c1f23a6b5e..ac319964f3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"hook/invoked","seq":1,"time":1783352209687,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352209706,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":19.49695100000008}} {"type":"user/message","seq":3,"time":1783352209707,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1783352209707,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"step/start","seq":6,"time":1783352209709,"data":{"turn":1,"step":1}} {"type":"request/header","seq":7,"time":1783352209710,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index cbb52c1b08..381458957c 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -106,7 +106,7 @@ {"type":"sandbox/mode","seq":104,"time":1784518115842,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":105,"time":1783962244624,"data":{"policy":"never"}} {"type":"user/message","seq":106,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"context/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} +{"type":"user/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} {"type":"step/start","seq":108,"time":1783962244624,"data":{"turn":2,"step":1}} {"type":"request/header","seq":109,"time":1784000791271,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} {"type":"assistant/chunk","seq":110,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl index a277f2e997..1f7345dc80 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl @@ -35,7 +35,7 @@ {"type":"tool/call","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} {"type":"todo/write","seq":34,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} {"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[33],"surfaceOp":"append"} -{"type":"context/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} +{"type":"user/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} {"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -58,7 +58,7 @@ {"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}} {"type":"todo/write","seq":57,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}} {"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"context/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} +{"type":"user/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"} {"type":"step/end","seq":60,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":61,"time":0,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 8293ea3abf..883e685a6d 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} {"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"context/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index c7f5d48562..58256cbda4 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -151,7 +151,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') - const workspaceContext = events.find(event => event.type === 'context/message' + const workspaceContext = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && typeof event.data.meta === 'object' && event.data.meta !== null && !Array.isArray(event.data.meta) diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 1626489bf5..a1ce4b6cc5 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -224,7 +224,7 @@ describe('headless stream-json snapshots', () => { .map(record => (record.data as JsonObject | undefined)?.name) expect(calls).toEqual(['create_goal', 'get_goal']) const goalChanges = records.filter((record) => { - if (record.type !== 'context/message') return false + if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined const meta = data?.meta as JsonObject | undefined return meta?.kind === 'goal/change' diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 05a1282a6f..5005192ec8 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -11,7 +11,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 7d04dfe952..7d6d9de97d 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => { expect(resultText(toolResult)).toContain('[exit code: 9]') }) - it('background: start ack → completion notice as context/message → task_output collects it', async () => { + it('background: start ack → completion notice as user/message → task_output collects it', async () => { // The task id is deterministic (a fresh TaskService counts per kind from 1), // so the script can name `bash-1` without threading a generated id. const adapter = new MockAdapter([ @@ -188,10 +188,12 @@ describe('bash tool through the agent loop', () => { expect(resultText(firstResult)).toBe('started background task bash-1') // The task settles on its own; the tool-tasks notice listener injects a - // durable context/message into the owning agent's session (settlement may - // race turn end, so poll for it). - await pollUntil(() => events(agent).some(event => event.type === 'context/message')) - const notice = findEvent(events(agent), 'context/message') + // durable plugin-sourced user/message into the owning agent's session + // (settlement may race turn end, so poll for it). + const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> => + e.type === 'user/message' && e.data.source.kind === 'plugin' + await pollUntil(() => events(agent).some(isNotice)) + const notice = events(agent).find(isNotice)! expect(notice.data.content.some( block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'), )).toBe(true) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bbf4809466..f474f56485 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -42,7 +42,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) if (turn % 9 === 4) { - push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) + push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) } push({ type: 'step/start', data: { turn, step: 0 } }) const withTool = turn % 5 === 2 diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index ccb48a0161..d10bcea074 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -38,6 +38,14 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': + // Injected context (plugin/goal source) folds to a context node, not a + // user message; only a direct human prompt is a user node. + if (event.data.source.kind !== 'user') { + return { + kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, + meta: event.data.meta, + } + } return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } case 'assistant/message': return { @@ -46,11 +54,6 @@ function materializeNode( } case 'steering/message': return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source } - case 'context/message': - return { - kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, - meta: event.data.meta, - } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) return { @@ -63,7 +66,7 @@ function materializeNode( resultView, } } - /* v8 ignore next 2 -- defensive arm: fold output only carries the five + /* v8 ignore next 2 -- defensive arm: fold output only carries the four surface-eligible types, and each has a case above; reachable only if core adds an eligible type. */ default: diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 3214c44ee9..bb360e2a67 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -40,7 +40,7 @@ describe('FoldAdapter', () => { ev.user(0, '用户'), ev.assistant(1, 0, '助手'), at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }), - at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }), + at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }), ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'), ev.toolResult(5, 0, 'c1', '结果'), ] diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3ea658b64d..1db86d38e4 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -563,7 +563,10 @@ describe('pressure measurement and retention', () => { const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() expect(prefix).toHaveLength(1) - expect(session.events.some(event => event.type === 'context/message')).toBe(false) + // The routed request prefix must not reach the surface as its own message + // (the compaction summary itself is an expected plugin-sourced checkpoint). + expect(session.events.some(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false) }) it('uses the latest logged request envelope without an AgentOptions override', async () => { @@ -959,7 +962,7 @@ describe('compaction region transaction', () => { const compact = service() const session = conversation(2) compact.mutateDuringSummary = () => { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'concurrent surface mutation' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts index 5f48e7f99f..3de0473d57 100644 --- a/packages/compact/compact/tests/tool-pairing.spec.ts +++ b/packages/compact/compact/tests/tool-pairing.spec.ts @@ -96,23 +96,23 @@ describe('tool-pairing boundaries', () => { content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }], provenance: { provider: 'mock', model: 'mock' }, }, SURFACE) - midStep.append('context/message', { + midStep.append('user/message', { content: [{ type: 'text', text: 'background update' }], source: { kind: 'plugin', plugin: 'test' }, }, SURFACE) midStep.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false, }, SURFACE) - expect(before(midStep, 'context/message')).toBe(false) - expect(after(midStep, 'context/message')).toBe(false) + expect(before(midStep, 'user/message')).toBe(false) + expect(after(midStep, 'user/message')).toBe(false) const free = new Session(SessionId('neutral-free')) - free.append('context/message', { + free.append('user/message', { content: [{ type: 'text', text: 'idle injection' }], source: { kind: 'user' }, }, SURFACE) - expect(before(free, 'context/message')).toBe(true) - expect(after(free, 'context/message')).toBe(true) + expect(before(free, 'user/message')).toBe(true) + expect(after(free, 'user/message')).toBe(true) }) }) diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts index bbb2a2c739..dea29b38ee 100644 --- a/packages/context/session-reference/src/projection.ts +++ b/packages/context/session-reference/src/projection.ts @@ -57,7 +57,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected break } case 'tool/result': - case 'context/message': break /* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */ default: diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index bb21cfab05..4496203dc7 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -61,7 +61,7 @@ function appendConversation(session: Session): void { { surfaceOp: 'append' }, ) session.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' } }, { surfaceOp: 'append' }, ) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 96ea7165af..fcf9e36efc 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -64,7 +64,6 @@ function precedingMessageTime(agent: Agent): number | undefined { case 'user/message': case 'assistant/message': case 'tool/result': - case 'context/message': case 'steering/message': return event.time default: @@ -79,7 +78,7 @@ function precedingMessageTime(agent: Agent): number | undefined { function precedingStepContextTime(agent: Agent, turn: number): number | undefined { for (const event of [...agent.session.events].reverse()) { if (event.type === 'turn/start' && event.data.turn === turn) return undefined - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === name) { return event.time @@ -91,7 +90,7 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine /** Find this plugin's latest durable injection, including a shadowed surface event. */ function latestInjectionTime(agent: Agent): number | undefined { for (const event of [...agent.session.events].reverse()) { - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === name) { return event.time diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 45fdb48cba..aa8f0418dd 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -48,7 +48,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa /** Validate one plugin-attributed time reading against its session position and timestamp. */ function validateReading( history: readonly SessionEvent[], - event: SessionEvent<'context/message'>, + event: SessionEvent<'user/message'>, fail: InvariantFailure, ): void { const [block] = event.data.content @@ -84,7 +84,7 @@ function validateReading( /** Validate all package-owned readings already present in one session. */ function validateSession(session: Session, fail: InvariantFailure): void { for (const [index, event] of session.events.entries()) { - if (event.type !== 'context/message' + if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) continue validateReading(session.events.slice(0, index), event, fail) @@ -97,7 +97,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - if (event.type !== 'context/message' + if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return validateReading(session.events, event, fail) diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index cd65f1aa3d..855303b295 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -17,7 +17,7 @@ async function setup(): Promise<Context> { function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent { return { - type: 'context/message', + type: 'user/message', seq: 0, time, data: { @@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session { } function appendReading(session: Session, text: string): void { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'time-context' }, }, { surfaceOp: 'append' }) @@ -162,7 +162,7 @@ describe('time-context invariants', () => { it('ignores context messages owned by another package', async () => { const ctx = await setup() - const other = event('unrelated') as SessionEvent<'context/message'> + const other = event('unrelated') as SessionEvent<'user/message'> other.data.source = { kind: 'plugin', plugin: 'other' } expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() other.data.source = { kind: 'user' } diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 2a0c06fe51..13edcfb036 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -48,7 +48,7 @@ describe('time-context through a real headless cordis.yml', () => { expect(stderr).not.toContain('UNHANDLED') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - const contexts = events.filter(event => event.type === 'context/message') + const contexts = events.filter(event => event.type === 'user/message') const starts = events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(2) expect(starts).toHaveLength(2) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index d1cd07207e..26bb5d7a9d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -43,9 +43,10 @@ function sessionAgent(session: Session, id = 'agent'): Agent { status: 'running', ctx: new Context(), send() {}, + followup() {}, steer() {}, inject(content, options) { - session.append('context/message', { + session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, }, { surfaceOp: 'append' }) @@ -66,7 +67,7 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] for (const event of session.events) { - if (event.type === 'context/message' + if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context') { texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') @@ -151,8 +152,8 @@ describe('durable step context', () => { + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', ]) const event = session.events.at(-1) - expect(event?.type).toBe('context/message') - if (event?.type !== 'context/message') throw new Error('missing time context') + expect(event?.type).toBe('user/message') + if (event?.type !== 'user/message') throw new Error('missing time context') expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) expect(event.surfaceOp).toBe('append') }) @@ -230,10 +231,10 @@ describe('durable step context', () => { const original = new Session(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) - const user = original.events.find(event => event.type === 'user/message') - const reading = original.events.find(event => event.type === 'context/message') + const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') if (user === undefined || reading === undefined) throw new Error('missing source surface events') - original.append('context/message', { + original.append('user/message', { content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'compact-basic' }, }, { @@ -292,7 +293,7 @@ describe('durable step context', () => { openMessageTurn(session, 1) let ordinarySawContext = false ctx.on('agent/pre-step', (subject) => { - ordinarySawContext = subject.session.events.some(event => event.type === 'context/message') + ordinarySawContext = subject.session.events.some(event => event.type === 'user/message') }) await fire(ctx, agent, 1, 1) @@ -401,7 +402,8 @@ describe('real agent-loop request history', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - const contexts = agent.session.events.filter(event => event.type === 'context/message') + const contexts = agent.session.events.filter( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') const starts = agent.session.events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 66b70f639d..61db3f527b 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -145,7 +145,7 @@ function visibleInstructionChanges( const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map<string, WorkspaceInstructionChange>() for (const [seq, event] of agent.session.events.entries()) { - if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue + if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.meta) for (const change of changes) { const waiting = pending.get(change.scope) @@ -281,7 +281,7 @@ export function observeInstructionSessionEvent( if (pending === undefined) return switch (event.type) { - case 'context/message': { + case 'user/message': { if (!isWorkspaceContextSource(event.data.source)) return for (const change of workspaceInstructionChanges(event.data.meta)) { const waiting = pending.get(change.scope) diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 9341eb33d0..f80ee5acde 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -107,15 +107,15 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode await waitForIdle(live.ctx, live.agent) const events = [...live.agent.session.events] - const update = events.find(event => event.type === 'context/message' + const update = events.find(event => event.type === 'user/message' && typeof event.data.meta === 'object' && event.data.meta !== null && !Array.isArray(event.data.meta) && event.data.meta.kind === 'workspace-instructions') - expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }], }) - const updateText = update?.type === 'context/message' + const updateText = update?.type === 'user/message' ? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('') : '' expect(updateText).toContain('Updated instructions from: AGENTS.md') diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c26be1a58e..c96cee8b38 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -175,9 +175,10 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session, status: 'idle', send() {}, + followup() {}, steer() {}, inject(content, options) { - session.append('context/message', { + session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, ...options?.meta !== undefined ? { meta: options.meta } : {}, @@ -219,7 +220,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext { function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined { let lastSeq: number | undefined for (const context of result.additionalContexts ?? []) { - lastSeq = agent.session.append('context/message', { + lastSeq = agent.session.append('user/message', { content: context.content, source: context.source, ...context.meta !== undefined ? { meta: context.meta } : {}, @@ -971,7 +972,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) expect(derivedText(agent)).toContain('repo rule') } finally { await rm(root, { recursive: true, force: true }) @@ -1143,7 +1144,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0) expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) @@ -1711,12 +1712,12 @@ describe('dynamic nested workspace context injection', () => { agent.send([{ type: 'text', text: 'read and abort' }]) await agent.whenIdle() - expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) agent.send([{ type: 'text', text: 'retry the read' }]) await agent.whenIdle() - const contexts = agent.session.events.filter(event => event.type === 'context/message') + const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') // The aborted batch drained its accepted context before step close, so the // retry sees durable history without producing a duplicate instruction. expect(contexts).toHaveLength(1) @@ -2490,10 +2491,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) appendAdditionalContexts(agent, first) - const resumed = { - ...agent, - session: new Session(agent.session.id, [...agent.session.events], agent.session.header), - } + const resumed = stubAgent(root, [...agent.session.events]) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -2531,11 +2529,11 @@ describe('dynamic nested workspace context injection', () => { await composeBaselinePrefix(ctx, resumed) - const update = resumed.session.events.findLast(event => event.type === 'context/message') - expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(update?.type === 'user/message' && update.data.meta).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) - expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume') + expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume') } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2681,7 +2679,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [ { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, { type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' }, @@ -2698,12 +2696,12 @@ describe('dynamic nested workspace context injection', () => { ], }, }, { surfaceOp: 'append' }) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [{ type: 'text', text: 'stale metadata version' }], source: { kind: 'plugin', plugin: 'workspace-context' }, meta: { kind: 'workspace-instructions', version: 0, changes: [] }, }, { surfaceOp: 'append' }) - agent.session.append('context/message', { + agent.session.append('user/message', { content: [{ type: 'text', text: 'foreign plugin context' }], source: { kind: 'plugin', plugin: 'other' }, meta: { @@ -3216,14 +3214,14 @@ describe('workspace context pending state', () => { path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one', }]])) - const unrelated = agent.session.append('context/message', { + const unrelated = agent.session.append('user/message', { content: [], source: { kind: 'plugin', plugin: 'other' }, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, unrelated, pending, versions) expect(pending.get(agent.session)?.has('pkg')).toBe(true) const otherContext = workspaceChangeContext('other', 'other') - const otherWorkspaceEvent = agent.session.append('context/message', { + const otherWorkspaceEvent = agent.session.append('user/message', { content: otherContext.content, source: otherContext.source, ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, @@ -3232,7 +3230,7 @@ describe('workspace context pending state', () => { expect(pending.get(agent.session)?.has('pkg')).toBe(true) const context = workspaceChangeContext('pkg', 'one') - const confirmed = agent.session.append('context/message', { + const confirmed = agent.session.append('user/message', { content: context.content, source: context.source, ...context.meta !== undefined ? { meta: context.meta } : {}, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3b9111f558..f5872f5fcc 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -843,6 +843,27 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, + { + name: 'agent/inbox/dequeue', + mode: 'emit', + signature: '\'agent/inbox/dequeue\'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void', + jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param info - the claimed item\'s accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', + }, + { + name: 'agent/inbox/discard', + mode: 'emit', + signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void', + jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', + }, + { + name: 'agent/inbox/enqueue', + mode: 'emit', + signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param info - the accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', + }, { name: 'agent/post-step', mode: 'serial', @@ -864,13 +885,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, - { - name: 'agent/queued', - mode: 'emit', - signature: '\'agent/queued\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void', - jsDoc: '/**\n * Detached, frozen content entered the agent\'s inbox. Source defaults have\n * already been applied, so these are the exact values retained for the log.\n * @param agent - the agent whose inbox received the message.\n * @param content - the accepted content blocks retained by the inbox.\n * @param info - the accepted source, contexts, and whether it entered as steering.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Detached, frozen content entered the agent\'s inbox.', - }, { name: 'agent/request', mode: 'waterfall', @@ -1127,14 +1141,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ - { - name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n}', - }, - { - name: 'AgentCancelCause', - declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};', - }, { name: 'AgentFactory', declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>;\n}', @@ -1147,10 +1153,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}', }, - { - name: 'AgentStatus', - declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', - }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -1451,10 +1453,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', }, - { - name: 'InjectOptions', - declaration: 'export interface InjectOptions extends Omit<SendOptions, \'contexts\'> {\n meta?: JsonValue;\n}', - }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', @@ -1525,7 +1523,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptMessageData', - declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}', + declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}', }, { name: 'PromptMessageEnvelope', @@ -1627,6 +1625,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', }, + { + name: 'RequestHeaderReason', + declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}', @@ -1659,17 +1661,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScopeKey', declaration: 'export type ScopeKey = object;', }, - { - name: 'SendOptions', - declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n}', - }, { name: 'SessionEvent', declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: Req /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', }, { name: 'SessionEventReadRequest', @@ -1881,7 +1879,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SurfaceEventType', - declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';', + declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, { name: 'SurfaceOp', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1b89f288a8..a3b834394b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. +The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO (waking the driver unless `wakeup: false`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 91efbf7782..d118fb3063 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,8 +8,8 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { Agent } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, InboxItemInfo, SendOptions } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' @@ -100,7 +100,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): * the loop driver. Everything observable happens through session events and * the agent/* event taxonomy — plugins never need this class. */ -export class ReactLoopAgent implements Agent { +export class ReactLoopAgent extends Agent { /** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */ readonly #inbox = new Inbox() @@ -161,6 +161,7 @@ export class ReactLoopAgent implements Agent { public readonly session: Session, maxParallelToolCalls: number, ) { + super() this.maxParallelToolCalls = maxParallelToolCalls const { promise, resolve } = Promise.withResolvers<void>() this.disposed = promise @@ -190,25 +191,25 @@ export class ReactLoopAgent implements Agent { for (const resolve of waiters) resolve() } - private resolveSource(options?: SendOptions): MessageSource { - return options?.source ?? { kind: 'user' } - } - /** * Accept one public message payload as a detached record. Lossless-JSON * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { - const source = this.resolveSource(options) + private acceptMessage(content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage { const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ content, source, contexts }) + const accepted = snapshotJsonValue({ content, source, contexts, wakeup }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } return deepFreeze(accepted) } + /** Build the `agent/inbox/*` payload for one accepted item. */ + private inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { + return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } + } + /** Detach one context before it can outlive its caller in the active-batch FIFO. */ private acceptContext(context: HookContext): HookContext { const accepted = snapshotJsonValue(context) @@ -225,24 +226,26 @@ export class ReactLoopAgent implements Agent { send(content: ContentBlock[], options?: SendOptions): void { this.assertNotDisposed() - const accepted = this.acceptMessage(content, options) - this.#inbox.enqueue(accepted) - const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const - agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) + const target = options?.target ?? 'next-turn' + const wakeup = options?.wakeup ?? true + // next-step/no-wakeup is injection: durable context without running the model. + if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return } + // next-step/wakeup is steering into the running turn; idle falls back to a + // woken follow-up turn (there is no active turn to attach to). + const steering = target === 'next-step' && this._status === 'running' + const source = options?.source ?? { kind: 'user' } + const accepted = this.acceptMessage(content, source, wakeup, options) + if (steering) { + this.#inbox.steer(accepted) + } else { + this.#inbox.enqueue(accepted, wakeup) + } + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', this.inboxInfo(accepted, steering)) } - steer(content: ContentBlock[], options?: SendOptions): void { - this.assertNotDisposed() - if (this._status !== 'running') { this.send(content, options); return } - const accepted = this.acceptMessage(content, options) - this.#inbox.steer(accepted) - const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const - agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) - } - - inject(content: ContentBlock[], options?: InjectOptions): void { - this.assertNotDisposed() - const source = this.resolveSource(options) + /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ + private injectContext(content: ContentBlock[], options?: SendOptions): void { + const source = options?.source ?? { kind: 'plugin', plugin: '' } const context = { content, source, @@ -257,7 +260,7 @@ export class ReactLoopAgent implements Agent { this.deferredInjections.push(accepted) return } - this.session.append('context/message', accepted, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) return } // No turn open: wrap the injection in a one-shot turn so every event stays @@ -269,7 +272,7 @@ export class ReactLoopAgent implements Agent { // are contained by Session and cannot create a false append failure. try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('context/message', context, { surfaceOp: 'append' }) + this.session.append('user/message', context, { surfaceOp: 'append' }) } finally { // Close the turn if turn/start made it into the log. A pre-commit veto // must escape rather than being mistaken for a committed turn/end. @@ -301,7 +304,7 @@ export class ReactLoopAgent implements Agent { private drainDeferredInjections(): void { const pending = this.deferredInjections.splice(0) for (const accepted of pending) { - this.session.append('context/message', accepted, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) } } @@ -325,10 +328,14 @@ export class ReactLoopAgent implements Agent { } } - cancel(cause?: AgentCancelCause): void { + cancel(cause?: AgentCancelCause, options?: CancelOptions): void { const resolvedCause = cause ?? { kind: 'user' } + const keepInbox = options?.keepInbox ?? false const cancellation = this.turnCancellation - const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering) + // keepInbox preserves pending work, so un-started items must not arm the + // pre-run cancel path that would otherwise drop the next queued turn. + const preRun = !keepInbox && cancellation === undefined + && (this.#inbox.hasQueued || this.#inbox.hasSteering) if (cancellation !== undefined || preRun) { if (preRun) this.preRunCancelled = true // Coordination consumers must update their own state before this call @@ -336,9 +343,18 @@ export class ReactLoopAgent implements Agent { // contained by the fused dispatcher and cannot veto cancellation. agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } - // Clear work already present before abort observers run. A replacement - // synchronously enqueued by an observer belongs to the next turn. - this.#inbox.clear() + if (!keepInbox) { + // Snapshot before clearing so the discard notification carries the exact + // dropped items; a replacement synchronously enqueued by an + // `agent/cancel-requested` observer belongs to the next turn, not here. + const discarded = this.#inbox.pending() + // Clear work already present before abort observers run. + this.#inbox.clear() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => this.inboxInfo(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } + } cancellation?.request(resolvedCause) } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index 6c8a20e3d1..a5ddd87b1f 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -1,7 +1,7 @@ /** * Per-agent message inbox: queued and steering FIFOs. Purely an in-memory - * mechanism of the loop driver — the public surface is `Agent.send()` and - * `Agent.steer()`. + * mechanism of the loop driver — the public surface is `Agent.send()` and its + * fixed-preset aliases. * * @module dsh-agent-loop/inbox */ @@ -14,12 +14,14 @@ export interface InboxMessage { content: ContentBlock[] source: MessageSource contexts: HookContext[] + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean } /** * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of - * the loop — the public surface is `Agent.send()` / `Agent.steer()`. + * the loop — the public surface is `Agent.send()` and its aliases. */ export class Inbox { private queuedMessages: InboxMessage[] = [] @@ -37,18 +39,21 @@ export class Inbox { } /** - * Add a message to the queued FIFO and wake a parked {@link waitForQueued}. + * Add a message to the queued FIFO, waking a parked {@link waitForQueued} + * unless the item opted out. A non-waking item still runs once any woken + * item or later wakeup drives the parked loop. * @param message - the message to queue for the next turn start. + * @param wake - whether to wake a parked idle wait (default true). */ - enqueue(message: InboxMessage): void { + enqueue(message: InboxMessage, wake = true): void { this.queuedMessages.push(message) - this.wakeup?.() + if (wake) this.wakeup?.() } /** * Add a message to the steering FIFO. Deliberately no wakeup: steering is * drained between steps of a running turn, never by the idle wait — - * `Agent.steer()` on an idle agent falls back to `send()` instead. + * `Agent.steer()` on an idle agent falls back to a woken follow-up instead. * @param message - the message to inject between steps of the running turn. */ steer(message: InboxMessage): void { @@ -71,6 +76,18 @@ export class Inbox { return this.steeringMessages.splice(0) } + /** + * Snapshot the pending items (queued then steering, FIFO order) without + * removing them — the discard notification's payload source. + * @returns the pending items paired with whether each is steering. + */ + pending(): { message: InboxMessage; steering: boolean }[] { + return [ + ...this.queuedMessages.map(message => ({ message, steering: false })), + ...this.steeringMessages.map(message => ({ message, steering: true })), + ] + } + /** * Discard all pending messages (queued + steering) without delivering them — * used by `cancel()`, which drops un-started work rather than draining it into diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 1cfd913b77..c13fa5a74f 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,7 +10,7 @@ import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFai import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, InboxItemInfo, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -19,9 +19,14 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' -import type { Inbox } from './inbox.ts' +import type { Inbox, InboxMessage } from './inbox.ts' import type { TurnCancellation } from './cancellation.ts' +/** Build the `agent/inbox/dequeue` payload for one claimed inbox item. */ +function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { + return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } +} + /** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): RequestError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) @@ -279,10 +284,11 @@ async function runTurn( const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { + events.emit('agent/inbox/dequeue', inboxInfo(message, true)) const prepared = preparePromptMessage(message.content, message.source, message.contexts) session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { - session.append('context/message', { + session.append('user/message', { content: context.content, source: context.source, ...context.meta === undefined ? {} : { meta: context.meta }, @@ -296,6 +302,7 @@ async function runTurn( const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') + events.emit('agent/inbox/dequeue', inboxInfo(message, false)) const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } @@ -538,7 +545,7 @@ async function runTurn( // A continuation reason becomes next-step steering. if (decision.action === 'continue' && decision.reason) { - handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] }) + handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true }) } let shouldContinue = decision.action === 'continue' diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 2cb3191f83..608f1bad60 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -139,7 +139,7 @@ describe('Agent', () => { agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } }) expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.at(-1)!.type).toBe('context/message') + expect(agent.session.events.at(-1)!.type).toBe('user/message') // Close the turn; now inject must wrap its own one-shot injection turn. agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -151,6 +151,16 @@ describe('Agent', () => { expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed }) + it('inject() defaults its source to an empty plugin, never user', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + agent.inject([{ type: 'text', text: 'no explicit source' }]) + const injected = agent.session.events.at(-1)! + expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' }) + }) + it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -202,7 +212,7 @@ describe('Agent', () => { expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow() const types = agent.session.events.map(e => e.type) - expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced + expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced await new Promise(r => setTimeout(r, 10)) expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 162d3b552a..11dc77ed5d 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -98,6 +98,25 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) }) + it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const discards: unknown[] = [] + ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) }) + + // Queue a turn WITHOUT waking the driver, so it sits in the inbox. + agent.send([{ type: 'text', text: 'preserved' }], { target: 'next-turn', wakeup: false }) + // keepInbox cancel: no active turn, work preserved, no discard event. + agent.cancel({ kind: 'user' }, { keepInbox: true }) + expect(discards).toEqual([]) + + // The preserved item still runs once the driver is woken by a later send. + send(agent, 'wake it') + await waitForIdle(ctx, agent) + expect(userTexts(agent)).toEqual(['preserved', 'wake it']) + }) + it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 3a9ca87d68..3b7aa8170c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -275,7 +275,9 @@ describe('abort during tool execution ends the turn', () => { order.push(`tool/result:${event.data.callId}:${outcome}`) break } - case 'context/message': order.push('context/message'); break + // Injected context is a plugin-sourced user/message; the direct human + // prompt (user source) is not tracked in this ordering. + case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break case 'steering/message': order.push('steering/message'); break case 'step/end': order.push('step/end'); break case 'turn/end': { @@ -354,13 +356,14 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] + const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || event.type === 'context/message' + .filter(event => event.type === 'tool/result' || isInjected(event) || event.type === 'step/end' || event.type === 'turn/end') - .map(event => event.type)) + .map(event => isInjected(event) ? 'context/message' : event.type)) .toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end']) expect(events - .filter(event => event.type === 'context/message') + .filter(isInjected) .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before abort' }], @@ -410,12 +413,13 @@ describe('abort during tool execution ends the turn', () => { await waitForIdle(ctx, agent) const events = [...agent.session.events] + const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user' expect(events - .filter(event => event.type === 'tool/result' || event.type === 'context/message' + .filter(event => event.type === 'tool/result' || isInjected(event) || event.type === 'step/end' || event.type === 'turn/end') - .map(event => event.type)) + .map(event => isInjected(event) ? 'context/message' : event.type)) .toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end']) - expect(events.find(event => event.type === 'context/message')?.data.content) + expect(events.find(isInjected)?.data.content) .toEqual([{ type: 'text', text: 'accepted after first result' }]) }) @@ -456,7 +460,7 @@ describe('abort during tool execution ends the turn', () => { await fiber.dispose() expect(agent.session.events - .filter(event => event.type === 'context/message') + .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user') .map(event => event.data.content)) .toEqual([ [{ type: 'text', text: 'accepted before disposal' }], @@ -507,7 +511,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.find(event => event.type === 'context/message')?.data.content) + expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content) .toEqual([{ type: 'text', text: 'new turn context' }]) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') }) @@ -763,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/queued carries the resolved source; steering/message records its source', async () => { + it('agent/inbox/enqueue carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -778,7 +782,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { })) const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = [] - ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info)) + ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering })) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) @@ -800,11 +804,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => { let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined let notifiedContexts: HookContext[] | undefined - ctx.on('agent/queued', (subject, acceptedContent, info) => { + ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || info.steering) return // Retain the exact notification references: cloning here would test the // listener's copy rather than the event/inbox ownership boundary. - notifiedContent = acceptedContent + notifiedContent = info.content notifiedSource = info.source notifiedContexts = info.contexts }) @@ -863,9 +867,9 @@ describe('adapter registration, routing, and accepted-input ownership', () => { let notifiedContent: ContentBlock[] | undefined let notifiedSource: MessageSource | undefined let notifiedContexts: HookContext[] | undefined - ctx.on('agent/queued', (subject, acceptedContent, info) => { + ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return - notifiedContent = acceptedContent + notifiedContent = info.content notifiedSource = info.source notifiedContexts = info.contexts }) @@ -951,7 +955,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(request).not.toContain('caller-mutated-steering-context-without-meta') const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message') - const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message' + const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context') expect(steeringIndex).toBeGreaterThanOrEqual(0) expect(contextIndex).toBe(steeringIndex + 1) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 7d4e79238e..700a4ac017 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -47,7 +47,7 @@ describe('inbox acceptance', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let queued = 0 - ctx.on('agent/queued', () => { queued += 1 }) + ctx.on('agent/inbox/enqueue', () => { queued += 1 }) expect(() => { agent.send([{ type: 'text', text: 'first', bad: 1n } as never]) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 99cae1ae77..91858e7050 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Inbox } from '../src/inbox.ts' function message(text: string) { - return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [] } + return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } function resolverPair() { @@ -25,6 +25,32 @@ describe('Inbox', () => { expect(inbox.dequeueQueued()).toBeUndefined() }) + it('enqueue(msg, false) queues without waking a parked waiter', async () => { + const inbox = new Inbox() + let woke = false + const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true }) + inbox.enqueue(message('quiet'), false) + // The item is queued, but the parked waiter was not resolved by it. + expect(inbox.hasQueued).toBe(true) + await Promise.resolve() + expect(woke).toBe(false) + // A later waking enqueue resolves the same waiter. + inbox.enqueue(message('loud')) + await waiter + expect(woke).toBe(true) + }) + + it('pending() snapshots queued then steering without removing them', () => { + const inbox = new Inbox() + inbox.enqueue(message('q')) + inbox.steer(message('s')) + const pending = inbox.pending() + expect(pending.map(p => p.steering)).toEqual([false, true]) + // Snapshot does not drain the FIFOs. + expect(inbox.hasQueued).toBe(true) + expect(inbox.hasSteering).toBe(true) + }) + it('pushes and drains steering messages separately from queued', () => { const inbox = new Inbox() inbox.steer(message('steer')) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 42a2808170..b225e67b1c 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -87,7 +87,7 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') }) - it('allow with additionalContexts injects separate context/message events into the turn', async () => { + it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -107,12 +107,12 @@ describe('agent/prompt-submit', () => { await waitForIdle(ctx, agent) const log = events(agent) - const userMsg = log.find(e => e.type === 'user/message') - const ctxMsg = log.find(e => e.type === 'context/message') + const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user') + const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(userMsg).toBeDefined() - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }]) - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }]) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) @@ -155,7 +155,7 @@ describe('agent/prompt-submit', () => { }], }, }) - expect(log.some(event => event.type === 'context/message')).toBe(false) + expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) expect(adapter.requests[0]?.messages.at(-1)).toEqual({ role: 'user', content: [ @@ -215,7 +215,6 @@ describe('agent/prompt-submit', () => { expect(log.some(e => e.type === 'turn/start')).toBe(true) expect(log.some(e => e.type === 'turn/end')).toBe(true) expect(log.some(e => e.type === 'user/message')).toBe(false) - expect(log.some(e => e.type === 'context/message')).toBe(false) expect(log.some(e => e.type === 'step/start')).toBe(false) // the veto is recorded durably as a prompt/blocked in the open turn const blocked = log.find(e => e.type === 'prompt/blocked') @@ -340,8 +339,8 @@ describe('agent/session-start', () => { // the injected context reached the model on the first (only) request expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') // and is recorded with the plugin source, never mislabeled as a user prompt - const ctxMsg = events(agent).find(e => e.type === 'context/message') - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) }) it('a throwing session-start listener does not abort agent construction', async () => { @@ -624,23 +623,22 @@ describe('tool additionalContexts buffering across a step', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Event order in the log: both tool/results, THEN both context/messages — + // Event order in the log: both tool/results, THEN both injected contexts — // never interleaved (which would break tool-call/result adjacency). - const types = events(agent).map(e => e.type) - const firstResult = types.indexOf('tool/result') - const lastResult = types.lastIndexOf('tool/result') - const firstCtx = types.indexOf('context/message') + const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + const seqs = events(agent) + const firstResult = seqs.findIndex(e => e.type === 'tool/result') + const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result') + const firstCtx = seqs.findIndex(e => e === injected[0]) expect(firstResult).toBeGreaterThanOrEqual(0) expect(lastResult).toBeGreaterThan(firstResult) // two results expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results // both contexts present - const ctxTexts = events(agent) - .filter(e => e.type === 'context/message') - .flatMap(e => (e.type === 'context/message' ? e.data.content : [])) + const ctxTexts = injected + .flatMap(e => (e.type === 'user/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) - const contextEvents = events(agent).filter(e => e.type === 'context/message') - expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) + expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) }) it('appends multiple contexts deferred by one composite tool after its outer result', async () => { @@ -661,14 +659,14 @@ describe('tool additionalContexts buffering across a step', () => { const log = events(agent) const resultIndex = log.findIndex(event => event.type === 'tool/result') - const contextEvents = log.filter(event => event.type === 'context/message') + const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(resultIndex).toBeGreaterThanOrEqual(0) expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex) - expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'a' }, { kind: 'plugin', plugin: 'b' }, ]) - expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) + expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) }) }) @@ -750,13 +748,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const log = events(agent) // session-start preamble injected - expect(log.some(e => e.type === 'context/message' + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true) - // prompt allowed → user/message recorded - expect(log.some(e => e.type === 'user/message')).toBe(true) + // prompt allowed → user-sourced user/message recorded + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true) // tool ran (echo allowed) and post-execute attached "audited" context expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true) - expect(log.some(e => e.type === 'context/message' + expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin' && e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true) // NO hook/* events — a native plugin needs none expect(log.some(e => e.type.startsWith('hook/'))).toBe(false) diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index aa8bd5d6d5..cb0dcd2384 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -42,7 +42,7 @@ describe('request-reconstruction invariant', () => { it('uses the step boundary rather than content appended afterward', async () => { const { ctx, session, boundary } = await requestSetup() - session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' }) const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id }) expect(() => { dispatch(ctx, options) }).not.toThrow() }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 5eaaa2ea5a..3fbb1d8958 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -380,7 +380,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) - // The idle inject records a self-contained turn (turn/start → context/message + // The idle inject records a self-contained turn (turn/start → user/message // → turn/end) so the event stays turn-enclosed, but does NOT run the model. await new Promise(r => setTimeout(r, 20)) expect(agent.status).toBe('idle') @@ -416,8 +416,8 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => event.type === 'context/message') - expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta }) + const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta }) const requestText = JSON.stringify(adapter.requests[0]!.messages) expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md') expect(requestText).not.toContain('<context source=') @@ -445,7 +445,7 @@ describe('agent loop', () => { }) first.text = 'mutated after inject' agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } }) - visibleDuringTool = agent.session.events.some(e => e.type === 'context/message') + visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, })) @@ -462,13 +462,13 @@ describe('agent loop', () => { const ts0 = turnStarts[0]! expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message') const result = agent.session.events.find(e => e.type === 'tool/result')! - const contexts = agent.session.events.filter(e => e.type === 'context/message') + const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) expect(result.seq).toBeLessThan(contexts[0]!.seq) - expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({ + expect(contexts[0]?.type === 'user/message' && contexts[0].data).toMatchObject({ meta, }) - expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : [])) + expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : [])) .toEqual([ { type: 'text', text: 'mid-turn notice' }, { type: 'text', text: 'second notice' }, @@ -512,7 +512,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false) + expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => { @@ -621,7 +621,7 @@ describe('agent loop', () => { ctx.on('agent/pre-step', (subject) => { if (subject === agent && !injected) { injected = true - subject.session.append('context/message', { + subject.session.append('user/message', { content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) @@ -639,7 +639,7 @@ describe('agent loop', () => { // And the injected event sits BEFORE the first step/start in the log — // the seam fired outside the step. const events = agent.session.events - const injectedSeq = events.find(e => e.type === 'context/message')!.seq + const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq expect(injectedSeq).toBeLessThan(firstStepStartSeq) }) @@ -1017,13 +1017,13 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message') }) - it('keeps a reentrant agent/queued send as the next independent turn', async () => { + it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let nested = false - ctx.on('agent/queued', (subject) => { + ctx.on('agent/inbox/enqueue', (subject) => { if (subject !== agent || nested) return nested = true send(agent, 'queued listener message') diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index aad90a7dde..405808ffd3 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -118,7 +118,7 @@ describe('request stability across the loop', () => { preStep() const session = agent.session const nodes = session.surface.nodes - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: '[summary of turn 1]' }], source: { kind: 'plugin', plugin: 'test-compact' }, }, { @@ -180,7 +180,7 @@ describe('request stability across the loop', () => { const first = adapter.requests[0]! // The inject landed in the log after the boundary: not in THIS request… expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false) - expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true) + expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 1e6bc14548..cb27b6b2f9 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -154,12 +154,15 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) const order: string[] = [] ctx.on('session/event', (_session, event) => { + // Injected context is a plugin-sourced user/message; the direct human + // prompt (user source) stays untracked as before. + const isInjected = event.type === 'user/message' && event.data.source.kind !== 'user' if ( event.type === 'assistant/message' || event.type === 'tool/call' - || event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'tool/result' || isInjected || event.type === 'steering/message' || event.type === 'step/end' ) { - if (!('step' in event.data) || event.data.step === 1) order.push(event.type) + if (!('step' in event.data) || event.data.step === 1) order.push(isInjected ? 'context/message' : event.type) } }) ctx.on('agent/post-step', (subject, turn, step, signal) => { @@ -271,7 +274,7 @@ describe('agent post-step and request-error lifecycle', () => { expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) attempts.push(history.length) - subject.session.append('context/message', { + subject.session.append('user/message', { content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], source: { kind: 'plugin', plugin: 'test-recovery' }, }, { surfaceOp: 'append' }) @@ -288,7 +291,7 @@ describe('agent post-step and request-error lifecycle', () => { const ends = agent.session.events.filter(event => event.type === 'step/end') expect(starts.map(event => event.data.step)).toEqual([1, 2]) expect(ends.map(event => event.data.step)).toEqual([1, 2]) - const recovery = agent.session.events.find(event => event.type === 'context/message')! + const recovery = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')! expect(ends[0]!.seq).toBeLessThan(recovery.seq) expect(recovery.seq).toBeLessThan(starts[1]!.seq) }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 35985c9185..9b95c825b0 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -403,11 +403,11 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = await waitForIdle(ctx, agent) const log = events(agent) - const contextTexts = log.filter(e => e.type === 'context/message') - .map(e => (e.data.content[0] as { text: string }).text) + const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + .map(e => ((e.data as { content: { text: string }[] }).content[0]!).text) expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2']) const lastResult = log.findLastIndex(e => e.type === 'tool/result') - const firstContext = log.findIndex(e => e.type === 'context/message') + const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(lastResult).toBeLessThan(firstContext) }) @@ -544,10 +544,11 @@ describe('tool-call scheduler: abort handling', () => { expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), ]) - const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') + const settled = events(agent).filter(e => e.type === 'tool/result' + || (e.type === 'user/message' && e.data.source.kind === 'plugin')) expect(settled.map(e => e.type)) - .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message']) - expect(settled.filter(e => e.type === 'context/message') + .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message']) + expect(settled.filter(e => e.type === 'user/message') .map(e => (e.data.content[0] as { text: string }).text)) .toEqual(['ctx-c1', 'ctx-c2']) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index b040d8cbdc..06a7e618f9 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. -`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent `context/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata. +`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). @@ -56,10 +56,11 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become `context/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. -- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. +- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. +- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. +- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. +- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` @@ -107,6 +108,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo - **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work. - **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam. - **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. -- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). +- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index 1902f3e746..a5a7725707 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -24,6 +24,27 @@ const install: InvariantInstaller = (ctx, fail) => { } lastStatus.set(agent, status) }, { global: true }) + + // Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped + // (discard) only after it entered (enqueue), so the live outstanding count + // per agent can never go negative. Injection bypasses the FIFOs entirely and + // never appears on these events. + const outstanding = new WeakMap<Agent, number>() + ctx.on('agent/inbox/enqueue', (agent) => { + outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1) + }, { global: true }) + ctx.on('agent/inbox/dequeue', (agent) => { + const count = outstanding.get(agent) ?? 0 + if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue') + outstanding.set(agent, count - 1) + }, { global: true }) + ctx.on('agent/inbox/discard', (agent, items) => { + const count = outstanding.get(agent) ?? 0 + if (items.length > count) { + fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`) + } + outstanding.set(agent, count - items.length) + }, { global: true }) } /** diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index dc78d76ef5..f04c999cb6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -26,10 +26,33 @@ export interface AgentOptions { } /** - * Message options. An omitted source attests direct human input as `{ kind: 'user' }` - * and may authorize policy consumers, so non-human producers must label their content. + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — the item joins the active turn between steps as steering, + * or, when no turn is active, is promoted per its `wakeup` flag. + */ +export type SendTarget = 'next-turn' | 'next-step' + +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * An omitted source attests direct human input as `{ kind: 'user' }` and may + * authorize policy consumers, so non-human producers must label their content. */ export interface SendOptions { + /** Queue the item joins; defaults to `next-turn`. */ + target?: SendTarget + /** + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). Defaults to + * `true`. A `false` `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). + */ + wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -37,19 +60,44 @@ export interface SendOptions { * records them directly at its next checkpoint. */ contexts?: HookContext[] + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } -/** Options specific to durable synthetic context injection. */ -export interface InjectOptions extends Omit<SendOptions, 'contexts'> { - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue +/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ +export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'> + +/** + * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` + * live events. Source defaults are already applied, so these are the exact + * values the item was accepted with. `steering` is true for a `next-step` + * item drained between steps; a `next-turn` item is claimed at a turn boundary. + */ +export interface InboxItemInfo { + content: ContentBlock[] + source: MessageSource + contexts: HookContext[] + /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + steering: boolean + /** Whether the item is marked to wake the driver or force a continuation. */ + wakeup: boolean +} + +/** Options for {@link Agent.cancel}. */ +export interface CancelOptions { + /** + * Preserve queued and steering inbox items instead of discarding them. The + * active turn is still aborted, but un-started and pending work survives for a + * later turn and no `agent/inbox/discard` fires. + */ + keepInbox?: boolean } /** * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (the driver is draining * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`steer`/`inject` throw). + * transition leaves it, and `send`/`followup`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -58,8 +106,8 @@ export interface HookContext { content: ContentBlock[] source: MessageSource /** - * Model placement. Absent or `separate` records an independent - * `context/message`; `prompt-prefix` prepends this context and a stable + * Model placement. Absent or `separate` records an independent injected + * `user/message`; `prompt-prefix` prepends this context and a stable * request delimiter to the same user-role message as its attached prompt. */ placement?: 'separate' | 'prompt-prefix' @@ -109,58 +157,100 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ -export interface Agent { +/** + * Public agent handle; its concrete implementation is internal to + * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so + * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, + * {@link Agent.inject}) are shared concrete delegates over the single abstract + * {@link Agent.send} primitive; concrete drivers implement `send` once. + */ +export abstract class Agent { /** The single identity shared with {@link session}. */ - readonly id: SessionId - readonly options: AgentOptions - readonly session: Session - readonly status: AgentStatus + abstract readonly id: SessionId + /** The provider route and model this agent's requests use. */ + abstract readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + abstract readonly session: Session + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + abstract readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context + abstract readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole - * ordinary message in its FIFO-ordered turn; the next claimed item waits for - * that turn's checkpoint. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * Detaches, validates, and freezes one lossless-JSON item, then routes it: + * + * - `next-turn` (default) queues an item that becomes the sole ordinary + * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` submits steering into the active turn + * (idle falls back to a woken `next-turn`). + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: an open turn joins at the current log position + * (deferred behind an executing tool batch until it settles), and an idle + * inject records a one-shot turn with its own durability checkpoint. + * * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before notification or enqueue. + * input throws synchronously before any notification, enqueue, or append. + * @param content - the model-facing content blocks to deliver. + * @param options - target queue, wakeup decision, source, contexts, and meta. */ - send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): void /** - * Submit steering while the agent is `running`. An open turn records it at - * the next steering checkpoint before a request or continuation decision; - * policy may stop before another step. After turn close and its checkpoint, - * any remainder is queued for a later turn; terminal `agent/turn-stop`, - * cancellation, or disposal may discard it. Uses the same synchronous - * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. - */ - steer(content: ContentBlock[], options?: SendOptions): void - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before turn - * close even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. - */ - inject(content: ContentBlock[], options?: InjectOptions): void - - /** - * Clear all queued and steering work, including items waiting to start, and - * abort the active turn. An effective call first emits - * `agent/cancel-requested` with the resolved typed cause. The first cause wins - * for the active turn, and `whenIdle()` resolves after cancellation reaches - * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op - * and does not arm later work. The active turn snapshots and freezes the cause. + * Clear queued and steering work — unless `keepInbox` — and abort the active + * turn. An effective call first emits `agent/cancel-requested` with the + * resolved typed cause. The first cause wins for the active turn, and + * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause + * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm + * later work. The active turn snapshots and freezes the cause. * @param cause - the stable caller intent carried by the current turn signal. + * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause?: AgentCancelCause): void + abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - whenIdle(): Promise<void> + abstract whenIdle(): Promise<void> + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param content - the prompt content blocks. + * @param options - source and attached contexts. + */ + followup(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-turn', wakeup: true }) + } + + /** + * Submit steering into the running turn — the `next-step`/wakeup preset of + * {@link send}. An open turn records it at the next steering checkpoint before + * a request or continuation decision; policy may stop before another step. + * After turn close and its checkpoint, any remainder is queued for a later + * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. + * Idle steering falls back to a woken follow-up turn. + * @param content - the steering content blocks. + * @param options - source and attached contexts. + */ + steer(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: true }) + } + + /** + * Append detached model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins + * at the current log position unless the current tool batch is executing; + * then it waits FIFO until that batch settles and drains before turn close + * even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through + * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + */ + inject(content: ContentBlock[], options?: AliasSendOptions): void { + this.send(content, { ...options, target: 'next-step', wakeup: false }) + } } declare module 'cordis' { @@ -196,15 +286,37 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void /** - * Detached, frozen content entered the agent's inbox. Source defaults have - * already been applied, so these are the exact values retained for the log. - * @param agent - the agent whose inbox received the message. - * @param content - the accepted content blocks retained by the inbox. - * @param info - the accepted source, contexts, and whether it entered as steering. + * A detached, frozen item entered the agent's inbox (queued or steering + * FIFO). Source defaults are already applied, so `info` holds the exact + * accepted values. This is the enqueue-time live signal; the durable record + * is the eventual `user/message`/`steering/message`. Injection + * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * @param agent - the agent whose inbox received the item. + * @param info - the accepted content, source, contexts, steering, and wakeup facts. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void + 'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void + /** + * The driver claimed one item out of the inbox: a queued item at a turn + * boundary, or steering drained between steps. Fires after the item leaves + * its FIFO and before it becomes a durable message. + * @param agent - the agent whose inbox item was claimed. + * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void + /** + * `cancel()` (without `keepInbox`) dropped pending inbox items without + * delivering them. Fires once per effective clearing call with every + * discarded item, after `agent/cancel-requested` and before the abort. + * @param agent - the agent whose inbox was cleared. + * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void /** * Effective broad cancellation was requested, before queued/steering work * is cleared or the active turn is aborted. This observe-only notification diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 509ad14a22..d157eeab57 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -3,26 +3,28 @@ import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { + Agent, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' -function stubAgent(rawId: string): Agent { +function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent { const id = SessionId(rawId) - return { + // Agent is an abstract class, so its alias methods live on the prototype and + // object spread would drop them; build the full literal and merge overrides. + return Object.assign(Object.create(Agent.prototype) as Agent, { id, options: {}, session: new Session(id), status: 'idle', ctx: new Context(), send() {}, - steer() {}, - inject() {}, cancel() {}, whenIdle() { return Promise.resolve() }, - } + ...overrides, + }) } describe('AgentRegistry', () => { @@ -56,7 +58,7 @@ describe('AgentRegistry', () => { it('rejects an agent whose registry and session identities differ', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) } + const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) }) expect(() => ctx.agents.enter(agent, undefined)) .toThrow('agent id "agent-id" does not match session id "session-id"') diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 3c0d147b9a..b850e8743a 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -56,3 +56,41 @@ describe('agent status invariants', () => { expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() }) }) + +describe('agent inbox invariants', () => { + const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) + + it('accepts a dequeue and a discard covered by prior enqueues', async () => { + const ctx = await setup() + const agent = mockAgent('i1') + const at = scopeTarget(agent, agent) + expect(() => { + ctx.emit(at, 'agent/inbox/enqueue', agent, info(false)) + ctx.emit(at, 'agent/inbox/enqueue', agent, info(true)) + ctx.emit(at, 'agent/inbox/dequeue', agent, info(false)) + ctx.emit(at, 'agent/inbox/discard', agent, [info(true)]) + }).not.toThrow() + }) + + it('rejects a dequeue with no outstanding item', async () => { + const ctx = await setup() + const agent = mockAgent('i2') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) }) + .toThrow(/without a matching prior enqueue/) + }) + + it('rejects a discard larger than the outstanding count', async () => { + const ctx = await setup() + const agent = mockAgent('i3') + const at = scopeTarget(agent, agent) + ctx.emit(at, 'agent/inbox/enqueue', agent, info(false)) + expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) }) + .toThrow(/dropped 2 items but only 1 were outstanding/) + }) + + it('accepts an empty discard against a fresh agent', async () => { + const ctx = await setup() + const agent = mockAgent('i4') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow() + }) +}) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 5988b58145..a12b0a513e 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -12,10 +12,12 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu 'agent/created': args => args[0], 'agent/disposed': args => args[0], 'agent/error': args => args[0], + 'agent/inbox/dequeue': args => args[0], + 'agent/inbox/discard': args => args[0], + 'agent/inbox/enqueue': args => args[0], 'agent/post-step': args => args[0], 'agent/pre-step': args => args[0], 'agent/prompt-submit': args => args[0], - 'agent/queued': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], 'agent/session-prefix': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 2d93bcddc5..bcb3944cbd 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -42,7 +42,9 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/queued': [agent, [], { source: { kind: 'user' }, contexts: [], steering: false }], + 'agent/inbox/enqueue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/dequeue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], 'agent/pre-step': [agent, 1, 1, signal], diff --git a/packages/core/session/README.md b/packages/core/session/README.md index c574174a20..958c226767 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history. +A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt (`user` source), a synthetic injection (`plugin`/`goal` source), or an admitted goal round — `source` is the only channel that tells them apart. It may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history. ### Session event vocabulary (`types.ts`) @@ -97,7 +97,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) #### What the model sees -The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. #### Token effect diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9c5831bb18..5e4320f046 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -532,10 +532,10 @@ export class Session { // trace/replay data. switch (event.type) { - // Injected context, ordinary prompts, and mid-turn steering project + // Ordinary prompts, injected context, and mid-turn steering project // identically in user role: the event's model-facing content stays // verbatim. A prompt envelope is model-hidden display metadata; its - // prefix bytes are already present in content. context's `source`/`meta` + // prefix bytes are already present in content. The message's `source`/`meta` // and steering's `turn` are also log-only. Do NOT // re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is // caller-owned — a producer bakes it into `content`, as workspace-context @@ -544,7 +544,6 @@ export class Session { // verbatim pass-through. See the deferred design note in // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md case 'user/message': - case 'context/message': case 'steering/message': { return { role: 'user', content: event.data.content } } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 9095c8388b..fc275129f9 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -15,14 +15,13 @@ const SURFACE_EVENT_TYPES = new Set<string>([ 'user/message', 'assistant/message', 'tool/result', - 'context/message', 'steering/message', ]) /** * Whether an event type can join the model-visible surface. * @param type - event type to test. - * @returns true for one of the five message-producing event types. + * @returns true for one of the four message-producing event types. */ export function isSurfaceEligibleType(type: string): boolean { return SURFACE_EVENT_TYPES.has(type) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 37c174ea12..3983034951 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -84,11 +84,12 @@ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `context/message` in a one-shot turn - * (`turn/start` → `context/message` → `turn/end`) so every event in the log - * stays turn-enclosed — the durability/replay boundary is the turn, and a - * bare event between turns would otherwise be indistinguishable from a crash - * tail on reload. + * was idle. The loop wraps the injected `user/message` (a non-`user` source, + * plugin by default) in a one-shot turn (`turn/start` → `user/message` → + * `turn/end`) so every event in the log stays turn-enclosed — the + * durability/replay boundary is the turn, and a bare event between turns would + * otherwise be indistinguishable from a crash tail on reload. The trigger's + * `source` mirrors that message's producer. */ injection: { kind: 'injection'; source: MessageSource } } @@ -201,7 +202,13 @@ export interface PromptMessageEnvelope { prefixContexts: PromptPrefixContext[] } -/** Shared payload for ordinary and steering prompt messages. */ +/** + * Shared payload for user, injected-context, and steering prompt messages. A + * direct human prompt, a synthetic `agent.inject()` context, and mid-turn + * steering all project into the model transcript as verbatim user-role content; + * they are told apart by `source` (a non-`user` kind marks injected context), + * not by event type. `meta` carries durable model-hidden producer state. + */ export interface PromptMessageData { /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ content: ContentBlock[] @@ -209,6 +216,15 @@ export interface PromptMessageData { source: MessageSource /** Present only when prompt-prefix contexts were baked into `content`. */ envelope?: PromptMessageEnvelope + /** + * Opaque durable JSON state retained on the event but hidden from the model + * projection. It is the intended channel for a future framing directive (a + * producer declares the frame, a dedicated renderer applies it — see the + * deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. + */ + meta?: JsonValue } /** @@ -236,29 +252,21 @@ export interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (the queued message claimed for this turn). */ + /** + * A user-role message on the model-visible surface: a direct human prompt + * (the queued message claimed for this turn), a synthetic `agent.inject()` + * context (file-change notices, subdir AGENTS.md, skill content, cron + * notifications, …), or an admitted goal continuation round. All three + * project their `content` verbatim; `source` (with a non-`user` kind marking + * injected context) is the only channel that tells them apart. An idle + * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + */ 'user/message': PromptMessageData /** * Durable record of a prompt veto and its reason. It is log-only: the blocked * prompt never enters the model-visible surface, and its turn runs zero steps. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } - /** - * In-session context injection (file-change notices, subdir AGENTS.md, - * skill content, cron notifications, …). Rendered into the derived history - * as a synthetic user-role message carrying `content` verbatim — NOT a - * user prompt. `meta` is durable JSON state omitted from the model - * projection; it is also the intended channel for any future framing - * directive (a producer declares the frame, a dedicated renderer applies it — - * see the deferred note in - * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), - * so the surface keeps projecting `content` verbatim rather than wrapping it. - */ - 'context/message': { - content: ContentBlock[] - source: MessageSource - meta?: JsonValue - } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -321,7 +329,6 @@ export type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' - | 'context/message' | 'steering/message' /** @@ -339,7 +346,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface * How a session event entered the ordered surface. Only valid on * {@link SurfaceEventType} events. * - * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * - `'append'`: added to the tail — normal path for user/assistant/tool/steering * messages. * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` * (inclusive) through `end` (inclusive) with this node. Both must exist as @@ -374,7 +381,7 @@ export interface SurfaceIntent { * * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, - * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * `assistant/message`, `tool/result`, `steering/message`). * Non-surface events (boundary markers, chunks, usage, errors) never carry * surface metadata — the compiler enforces this at `Session.append()` * call sites. diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 96d1f7048c..c2ff24936b 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -38,7 +38,7 @@ describe('derived-message cache', () => { expect(beforeReplace).toHaveLength(2) const nodes = session.surface.nodes - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d880153dd3..132a250a97 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -62,7 +62,7 @@ describe('Session', () => { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'before' }], source: { kind: 'plugin', plugin: 'before' }, }, { surfaceOp: 'append' }) @@ -82,7 +82,7 @@ describe('Session', () => { turn: 3, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'after' }], source: { kind: 'plugin', plugin: 'after' }, }, { surfaceOp: 'append' }) @@ -117,9 +117,9 @@ describe('Session', () => { .toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format') }) - it('renders context and steering messages as plain user content', () => { + it('renders injected-context and steering messages as plain user content', () => { const session = new Session(SessionId('s2')) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' }, }, { surfaceOp: 'append' }) @@ -172,7 +172,7 @@ describe('Session', () => { version: 1, changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }], } - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }], source: { kind: 'plugin', plugin: 'workspace-context' }, meta, @@ -183,7 +183,7 @@ describe('Session', () => { content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }], }]) const event = session.events[0] - expect(event?.type === 'context/message' && event.data.meta).toEqual(meta) + expect(event?.type === 'user/message' && event.data.meta).toEqual(meta) }) it('replays identically from a seeded event log', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index b7cbe11b11..cfb84f4e18 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -444,9 +444,9 @@ describe('deriveMessages with surface', () => { expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' }) }) - it('context/message and steering/message appear on surface', () => { + it('injected-context and steering/message appear on surface', () => { const s = new Session(SessionId('ctx')) - s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' }) + s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' }) s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const messages = s.deriveMessages() expect(messages).toHaveLength(2) @@ -524,7 +524,6 @@ describe('surface type guards', () => { expect(isSurfaceEligibleType('user/message')).toBe(true) expect(isSurfaceEligibleType('assistant/message')).toBe(true) expect(isSurfaceEligibleType('tool/result')).toBe(true) - expect(isSurfaceEligibleType('context/message')).toBe(true) expect(isSurfaceEligibleType('steering/message')).toBe(true) expect(isSurfaceEligibleType('turn/start')).toBe(false) expect(isSurfaceEligibleType('assistant/chunk')).toBe(false) @@ -568,7 +567,7 @@ describe('SurfaceManager.replaceGeneration', () => { expect(s.surface.replaceGeneration).toBe(0) const nodes = s.surface.nodes - s.append('context/message', { + s.append('user/message', { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] }) expect(s.surface.replaceGeneration).toBe(1) diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index e477924f3f..874ef4f91c 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -365,7 +365,7 @@ describe('runOneShot and executeCli', () => { const { ctx, agent } = await harness([textResponse('streamed')]) const other = ctx.sessions.create(SessionId('unrelated')) let injected = false - ctx.on('agent/queued', (subject) => { + ctx.on('agent/inbox/enqueue', (subject) => { if (subject !== agent || injected) return injected = true agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -379,7 +379,7 @@ describe('runOneShot and executeCli', () => { expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } }) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } }) expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true) - expect(events.some(event => event.type === 'context/message')).toBe(false) + expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) }) it('emits partial data and a diagnostic for non-completed turns', async () => { @@ -477,7 +477,7 @@ describe('runOneShot and executeCli', () => { const queued = await harness([textResponse('unused')]) const queuedAbort = new AbortController() - queued.ctx.on('agent/queued', (agent) => { + queued.ctx.on('agent/inbox/enqueue', (agent) => { if (agent === queued.agent) queuedAbort.abort('cancel queued') }) await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued') diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 35994ecb71..6ea767e091 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -26,11 +26,11 @@ function nextTurn(session: Session): number { } /** Append one idle injection using the public Agent contract's balanced shape. */ -function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { - const source: MessageSource = options?.source ?? { kind: 'user' } +function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { + const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content, source, ...options?.meta === undefined ? {} : { meta: options.meta }, @@ -49,6 +49,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } { ctx: new Context(), get status() { return status }, send() {}, + followup() {}, steer() {}, inject(content, options) { appendInjection(session, content, options) }, cancel() { status = 'idle' }, @@ -125,7 +126,7 @@ describe('/goal human command', () => { expect(created.text).toContain('Rounds: 0/256') expect(created.text).toContain('Activation: armed') expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release') - expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) const count = test.session.events.length await expect(run(test, ' replacement')).resolves.toEqual({ diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index bcb3fc2a75..a4883a0f7c 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -306,10 +306,10 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('agent/queued', (agent, content, info) => { + ctx.on('agent/inbox/enqueue', (agent, info) => { const state = stateFor(agent) const attempt = state.attempt - if (attempt !== undefined && sameQueued(content, info.source, attempt)) return + if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index f204893578..e323180e88 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -207,7 +207,9 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] for (const event of test.agent.session.events) { - if (event.type === 'user/message' && event.data.source.kind === 'goal') { + // Round zero is a durable goal state change; positive rounds are the + // admitted continuation prompts this test counts. + if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) { rounds.push(event.data.source.round) } } @@ -287,7 +289,7 @@ describe('same-session goal driving', () => { it('pauses and drops a reserved round when cancellation lands before admission', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent === test.agent && info.source.kind === 'goal') { cancel() agent.cancel({ kind: 'user' }) @@ -299,8 +301,10 @@ describe('same-session goal driving', () => { expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) expect(test.adapter.requests).toHaveLength(0) + // No admitted continuation round (positive round); goal state changes + // (round zero) are expected in the log. expect(test.agent.session.events.some(event => event.type === 'user/message' - && event.data.source.kind === 'goal')).toBe(false) + && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) it('pauses an admitted round when cancellation aborts an active step', async () => { @@ -334,7 +338,7 @@ describe('same-session goal driving', () => { const warnings: string[] = [] test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn let inserted = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') @@ -357,7 +361,7 @@ describe('same-session goal driving', () => { it('makes a reserved round stale when a listener queues human work behind it', async () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true agent.send([{ type: 'text', text: 'human joined the pending batch' }]) @@ -375,7 +379,7 @@ describe('same-session goal driving', () => { it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || edited) return edited = true const current = test.ctx.goals.get(agent) @@ -391,7 +395,7 @@ describe('same-session goal driving', () => { expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined) .toBe('stale goal-round reservation') const admitted = test.agent.session.events.find(event => event.type === 'user/message' - && event.data.source.kind === 'goal') + && event.data.source.kind === 'goal' && event.data.source.round > 0) expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' ? admitted.data.source.revision : undefined).toBe(2) @@ -477,8 +481,14 @@ describe('same-session goal driving', () => { it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { const test = await harness([]) - vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { - throw new Error('queue rejected') + // inject shares send, so reject only the round send (a goal-sourced + // next-turn item), not the goal state-change injection that precedes it. + const realSend = test.agent.send.bind(test.agent) + vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + throw new Error('queue rejected') + } + realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -494,9 +504,13 @@ describe('same-session goal driving', () => { it('preserves a custom agent side effect when send disarms before throwing', async () => { const test = await harness([]) - vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { - test.ctx.goals.disarm(test.agent) - throw new Error('queue rejected after disarm') + const realSend = test.agent.send.bind(test.agent) + vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + test.ctx.goals.disarm(test.agent) + throw new Error('queue rejected after disarm') + } + realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) @@ -554,7 +568,7 @@ describe('same-session goal driving', () => { it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { const test = await harness([textResponse('retry after containment')]) let armed = true - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -635,7 +649,7 @@ describe('same-session goal driving', () => { it('falls back to disarming when a cancelled reservation cannot be paused', async () => { const test = await harness([]) - const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal') return cancel() vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { @@ -689,7 +703,7 @@ describe('same-session goal driving', () => { it('cancels an accepted queued round and awaits its driver task during teardown', async () => { const test = await harness([]) let unloading: Promise<void> | undefined - test.ctx.on('agent/queued', (agent, _content, info) => { + test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { unloading = Promise.resolve(test.driver.dispose()) } diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 19200747e8..0427a41333 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -40,7 +40,7 @@ function view(roundsStarted: number): GoalView { function appendChange(session: Session): void { session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -126,7 +126,7 @@ describe('goal-session prompt invariants', () => { it('attributes an invalid durable prefix during late loading', async () => { const { ctx, session } = await mount(true) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'counterfeit goal state' }], source: changeSource, meta: change as never, diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index fe88ebcdba..2ff756249f 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -17,7 +17,7 @@ import type { GoalSnapshotChangeMeta, } from './types.ts' -type ContextMessageEvent = Extract<SessionEvent, { type: 'context/message' }> +type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }> const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([ 'create', @@ -310,17 +310,18 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v } /** - * Decode and verify one model-visible goal context event without folding it. - * @param event - context event whose metadata and rendered content must agree. - * @returns validated change or `undefined` for an unrelated context event. + * Decode and verify one model-visible goal state change without folding it. A + * goal state change is a round-zero goal-sourced `user/message` carrying + * `goal/change` metadata; any other user message returns `undefined`. Goal + * metadata on a non-goal source, or a mismatched attribution or rendered body, + * fails replay loudly. + * @param event - user message whose metadata and rendered content must agree. + * @returns validated change, or `undefined` when the message is not a goal state change. */ -export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined { +export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined { const change = decodeGoalChange(event.data.meta) + if (change === undefined) return undefined const source = goalSource(event.data.source) - if (change === undefined) { - if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) - return undefined - } const ref = goalChangeRef(change) if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) { throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`) @@ -338,23 +339,27 @@ export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | un * @returns decoded change for pending-overlay reconciliation. */ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined { - if (event.type === 'context/message') { - const change = decodeGoalEvent(event) - if (change === undefined) return undefined - applyGoalChange(state, change) - return change - } if (event.type === 'user/message') { - const source = goalSource(event.data.source) - if (source !== undefined) { - const current = state.goal - if (current === undefined || current.phase !== 'active' || source.goalId !== current.id - || source.revision !== current.revision || source.round !== state.roundsStarted + 1 - || source.round > current.maxGoalRounds) { - throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) - } - state.roundsStarted = source.round + // A goal state change carries `goal/change` metadata (round zero). + const change = decodeGoalEvent(event) + if (change !== undefined) { + applyGoalChange(state, change) + return change } + const source = goalSource(event.data.source) + if (source === undefined) return undefined + // A goal-sourced message without change metadata must be a positive-round + // admitted continuation prompt; round zero owes durable change metadata. + if (source.round === 0) { + throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) + } + const current = state.goal + if (current === undefined || current.phase !== 'active' || source.goalId !== current.id + || source.revision !== current.revision || source.round !== state.roundsStarted + 1 + || source.round > current.maxGoalRounds) { + throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) + } + state.roundsStarted = source.round } return undefined } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7391c69e93..e4700452fa 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -370,7 +370,9 @@ export class GoalService extends Service { /** Incrementally observe durable events without losing deferred mutations. */ private sync(session: Session, cache: GoalCache): void { for (const event of session.events.slice(cache.observedSeq)) { - if (event.type === 'context/message') { + // A goal state change is a round-zero goal-sourced user message; a + // positive round is a continuation prompt handled by applyGoalEvent. + if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) { const change = decodeGoalEvent(event) if (change !== undefined) { const pending = cache.pending[0] diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 49184faa8c..5a97cceae0 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -3,7 +3,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' -/** Version of the goal change metadata embedded in `context/message`. */ +/** Version of the goal change metadata embedded in a round-zero `user/message`. */ export const GOAL_CHANGE_VERSION = 1 /** diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 2c6798718d..7da3c525d7 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -89,7 +89,7 @@ export interface GoalClearChangeMeta { readonly clearedAt: number } -/** Durable metadata union carried by a goal-owned `context/message`. */ +/** Durable metadata union carried by a goal-owned round-zero `user/message`. */ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta /** Message attribution for durable goal state and continuation rounds. */ diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts index 0c582645bc..357bebe227 100644 --- a/packages/goal/goal/tests/goal.e2e.ts +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -50,11 +50,11 @@ describe('goal domain through a real cordis.yml and headless process', () => { expect(result['result']).toContain('CLI tool round trip complete') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1) - const contexts = events.filter(event => event.type === 'context/message' + const contexts = events.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') expect(contexts).toHaveLength(1) const context = contexts[0] - if (context?.type !== 'context/message') throw new Error('expected goal context event') + if (context?.type !== 'user/message') throw new Error('expected goal context event') const change = decodeGoalChange(context.data.meta) if (change === undefined) throw new Error('expected durable goal change') expect(change).toMatchObject({ diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index ad2011fc62..18f8d5dfe2 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import GoalService, { @@ -15,7 +15,7 @@ import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek- interface DeferredInjection { content: ContentBlock[] - options: InjectOptions | undefined + options: AliasSendOptions | undefined } interface StubAgent { @@ -33,8 +33,8 @@ function nextTurn(session: Session): number { } /** Mirror the public Agent.inject idle/open-turn contract for domain tests. */ -function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { - const source: MessageSource = options?.source ?? { kind: 'user' } +function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { + const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const context = { content, source, @@ -43,12 +43,12 @@ function appendInjection(session: Session, content: ContentBlock[], options?: In const last = session.events.at(-1) const open = last !== undefined && last.type !== 'turn/end' if (open) { - session.append('context/message', context, { surfaceOp: 'append' }) + session.append('user/message', context, { surfaceOp: 'append' }) return } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', context, { surfaceOp: 'append' }) + session.append('user/message', context, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } @@ -65,6 +65,7 @@ function stubAgentForSession(session: Session): StubAgent { ctx: new Context(), get status() { return status }, send() {}, + followup() {}, steer() {}, inject(content, options) { if (shouldDefer) deferred.push({ content, options }) @@ -131,10 +132,10 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end']) const context = session.events[1] - expect(context?.type).toBe('context/message') - if (context?.type !== 'context/message') throw new Error('expected goal context') + expect(context?.type).toBe('user/message') + if (context?.type !== 'user/message') throw new Error('expected goal context') expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 }) const change = decodeGoalChange(context.data.meta) if (change === undefined) throw new Error('expected decoded goal change') @@ -266,7 +267,9 @@ describe('GoalService creation and replay', () => { it('requires the exact live registry instance for reads and mutations', async () => { const { ctx, agent } = await harness() - const impostor = { ...agent, session: new Session(agent.id) } + // A same-id agent backed by a different session object — the live-instance + // check must reject it even though the ids match. + const impostor = stubAgentForSession(new Session(agent.id)).agent expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE', @@ -407,8 +410,8 @@ describe('GoalService mutations', () => { vi.setSystemTime(80) ctx.goals.clear(agent, goal) const clear = session.events - .filter(event => event.type === 'context/message') - .map(event => decodeGoalChange(event.data.meta)) + .filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') + .map(event => event.type === 'user/message' ? decodeGoalChange(event.data.meta) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) expect(() => foldGoal(session.events)).not.toThrow() @@ -454,7 +457,7 @@ describe('GoalService mutations', () => { ctx.agents.register(stub.agent) let observed: ReturnType<GoalService['get']> ctx.on('session/event', (session, event) => { - if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent) + if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent) }) const created = ctx.goals.create(stub.agent, { objective: 'publish once' }) @@ -517,7 +520,7 @@ describe('GoalService mutations', () => { const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source, meta: change as never, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -594,7 +597,7 @@ describe('goal replay validation', () => { } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: overrides.content ?? renderGoalChange(change), source, meta: change as never, @@ -791,7 +794,7 @@ describe('goal replay validation', () => { const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'missing' }], source, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) @@ -853,7 +856,7 @@ describe('goal replay validation', () => { const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(clear), source, meta: clear as never, }, { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index 85f0b839d1..996743ceca 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -45,7 +45,7 @@ describe('goal stream invariants', () => { const ctx = await setup() const session = ctx.sessions.create(SessionId('goal-invariant-valid')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -71,7 +71,7 @@ describe('goal stream invariants', () => { const session = ctx.sessions.create(SessionId('goal-invariant-invalid')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) expect(() => { - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'counterfeit' }], source: changeSource, meta: change as never, @@ -82,7 +82,7 @@ describe('goal stream invariants', () => { })) expect(session.seq).toBe(1) expect(() => { - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, @@ -95,7 +95,7 @@ describe('goal stream invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('goal-invariant-late-load')) session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: changeSource, meta: change as never, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index faaf9d1c76..171fe1c0ef 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { CallId } from '@deepseek-ai/dsh-llm' @@ -32,10 +32,11 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { get status() { return status }, ctx: new Context(), send() {}, + followup() {}, steer() {}, - inject(content: ContentBlock[], options?: InjectOptions) { - const source = options?.source ?? { kind: 'user' } - session.append('context/message', { + inject(content: ContentBlock[], options?: AliasSendOptions) { + const source = options?.source ?? { kind: 'plugin', plugin: '' } + session.append('user/message', { content, source, ...options?.meta === undefined ? {} : { meta: options.meta }, @@ -225,7 +226,9 @@ describe('goal tool execution authority', () => { it('rejects stale agent objects and agents outside running status through the executor', async () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) - const stale = { ...root.agent } + // A distinct agent object over root's exact session: same id, not the live + // registered instance, so the executor must reject it. + const stale = stubAgent('goal-tool-stale', root.agent.session).agent const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index b3c90021a5..5646641424 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -35,10 +35,10 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */ +/** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { return [...agent.session.events] - .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') + .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), source: e.data.source, diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 953b4befd0..e5858e8e54 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -125,8 +125,8 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { // The injected context reached the model and is recorded with the plugin source. expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief') - const ctxMsg = events(agent).find(e => e.type === 'context/message') - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' }) + const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user') + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' }) }) }) @@ -216,10 +216,10 @@ describe('hooks-claude bridge — PostToolUse', () => { const log = events(agent) const resultIdx = log.findIndex(e => e.type === 'tool/result') - const ctxIdx = log.findIndex(e => e.type === 'context/message') + const ctxIdx = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user') expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result const ctxMsg = log[ctxIdx] - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) + expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) }) it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => { @@ -262,7 +262,7 @@ describe('hooks-claude bridge — SessionStart', () => { // session-start fires async (detached .then → agent.inject); wait for the // injected context/message to actually land before sending, rather than a // fixed sleep that flakes under load. - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index e303c589be..63b127e794 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -138,9 +138,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - // The prompt proceeded unchanged; no context/message injected. + // The prompt proceeded unchanged; no injected context. expect(adapter.requests).toHaveLength(1) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { @@ -441,7 +441,7 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) // additionalContext also injected (the block + context arm). - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) }) it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { @@ -475,7 +475,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) - expect(events(handle.agent).some(e => e.type === 'context/message' + expect(events(handle.agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) await handle.dispose() }) @@ -496,7 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // the downstream block won: the model was never called, no user/message was // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) }) @@ -528,12 +528,12 @@ export function defineCoverageCases(group: CoverageGroup): void { // the original prompt was replaced by the downstream rewrite const userMsg = events(agent).find(e => e.type === 'user/message') expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { @@ -551,7 +551,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { @@ -573,12 +573,12 @@ export function defineCoverageCases(group: CoverageGroup): void { agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { @@ -599,7 +599,7 @@ export function defineCoverageCases(group: CoverageGroup): void { expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) // the bridge's context still landed (folded onto the block) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index d0f0df92f6..4cfdd93e82 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -136,12 +136,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro expect(req).toContain('from-bridge') expect(req).toContain('from-downstream') expect(req).toContain('rewritten-prompt') - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) }) @@ -157,7 +157,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { @@ -177,12 +177,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const contexts = events(agent).filter(event => event.type === 'context/message') - expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) + expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => { @@ -197,7 +197,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) it('SessionStart additionalContext is injected for the first request', async () => { @@ -206,7 +206,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') @@ -233,7 +233,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) }) @@ -345,7 +345,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) it('a throwing SessionStart inject is contained (logged)', async () => { @@ -428,7 +428,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) + expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) }) it('commandOf reads a non-string command arg as an empty command', async () => { @@ -521,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished - expect(events(agent).some(e => e.type === 'context/message' + expect(events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) }) @@ -545,7 +545,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' + await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 27d54c5a69..916a39baf1 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -332,7 +332,7 @@ export class PlanModeService extends Service { const text = target ? 'The user switched this session to plan mode.' : 'The user switched this session back to the default mode.' - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'plan-mode' }, }, { surfaceOp: 'append' }) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index f1c57b5938..1f78ae3511 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -92,7 +92,7 @@ describe('plan mode through the agent loop', () => { const result = findEvent(log, 'tool/result') expect(result.data.isError).toBe(false) expect(foldPlanMode(log)).toBe(true) - expect(log.some(event => event.type === 'context/message')).toBe(false) + expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => { @@ -115,9 +115,9 @@ describe('plan mode through the agent loop', () => { const log = agent.session.events expect(foldPlanMode(log)).toBe(true) - const notices = log.filter(event => event.type === 'context/message') + const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notices).toHaveLength(1) - expect(findEvent(log, 'context/message').data.content).toEqual([ + expect(notices[0]?.type === 'user/message' && notices[0].data.content).toEqual([ { type: 'text', text: 'The user switched this session to plan mode.' }, ]) // The changed request is logged as a complete snapshot. @@ -163,7 +163,8 @@ describe('plan mode through the agent loop', () => { expect(firstEnd?.seq).toBeLessThan(planMode.seq) expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0) expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section) - expect(findEvent(log, 'context/message').data.content).toEqual([ + const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + expect(notice?.type === 'user/message' && notice.data.content).toEqual([ { type: 'text', text: 'The user switched this session to plan mode.' }, ]) }) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index b5dc9723de..c3c0b4d2ea 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -95,7 +95,7 @@ function header(session: Session): void { function noticeTexts(session: Session): string[] { return session.events - .filter(event => event.type === 'context/message') + .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join('')) } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1273824033..360b71c3f8 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index b2d6a38255..ad463a0352 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 17b0302ea6..4a690ed520 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -28,6 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle', ctx: scopeFiber.ctx, send() {}, + followup() {}, steer() {}, inject() {}, cancel() {}, diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 38a5cb4ed6..f71a637928 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 5adcaea441..0439e5f876 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index ee8b1d833f..de31980f48 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -107,7 +107,7 @@ function appendTraceEvents(session: Session): void { { surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] }, ) session.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) @@ -309,14 +309,14 @@ describe('session event tracing', () => { const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) live.append( - 'context/message', + 'user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } }, { surfaceOp: 'append' }, ) TracePersistence.listFailure = new Error('list unavailable') TracePersistence.loadFailure = new Error('load unavailable') await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 })) - .resolves.toMatchObject({ target: { type: 'context/message' } }) + .resolves.toMatchObject({ target: { type: 'user/message' } }) expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 1cfb809601..a1401bbb6d 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -68,7 +68,7 @@ describe('startInProcessRun', () => { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'late metadata' }], source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 7ba0551f34..c2dbe00dc9 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -187,10 +187,10 @@ describe('dsh-subagent-spawn', () => { expect(published).toEqual([]) }) - it('a cancel from agent/queued maps a no-turn child log to aborted', async () => { + it('a cancel from agent/inbox/enqueue maps a no-turn child log to aborted', async () => { const { ctx, parent } = await setup([]) const controller = new AbortController() - ctx.on('agent/queued', () => { controller.abort('queued-window') }) + ctx.on('agent/inbox/enqueue', () => { controller.abort('queued-window') }) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) const result = await run.result expect(result).toMatchObject({ stopReason: 'aborted', output: [] }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index e4866212b6..2dd0cde142 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -34,7 +34,7 @@ The current executable companions protect these relationships: | Companion | Checks | |---|---| -| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | +| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, inbox FIFO conservation, scoped subjects, and model-request reconstruction. | | `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | | `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 0d3eae8338..b4d57191a2 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -24,6 +24,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle' as const, ctx: scopeFiber.ctx, send() {}, + followup() {}, steer() {}, inject() {}, cancel() {}, diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 7e0b15ff9c..b99b7a4ee2 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1333,8 +1333,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * generic fallback (title = tool name, raw args as input) when no registry is * available (e.g. pure translator tests). * - * Other event types (turn/step boundaries, context/message, …) produce - * no client update. + * Other event types (turn/step boundaries, injected-context user messages, …) + * produce no client update. * @param sessionId - the ACP session id stamped on every emitted notification. * @param event - the harness session event to translate. * @param notify - sink for each produced `session/update` notification; called @@ -1374,6 +1374,9 @@ export function streamSessionEventUpdate( } case 'user/message': { if (!includeUserMessages) return + // Only a direct human prompt replays as a user message; injected context + // (plugin/goal source) is not the user's turn and produces no update. + if (event.data.source.kind !== 'user') return // Replay the user's prompt so a loaded session shows both sides of each // turn. Live prompt turns suppress this path to avoid duplicating what // the client just sent. @@ -1420,7 +1423,7 @@ export function streamSessionEventUpdate( notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) return } - // non-error turn/step boundaries, context/message, steering, + // non-error turn/step boundaries, injected-context user messages, steering, // assistant/message — no direct ACP client update. default: return diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index 1e15910ce7..6e24acce24 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -383,7 +383,7 @@ describe('acp bridge', () => { }, }], }) - expect(target.events.some(event => event.type === 'context/message')).toBe(false) + expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) const request = JSON.stringify(harness.adapter.requests[0]?.messages) expect(request).toContain('untrusted, read-only snapshot') expect(request).toContain('source background') diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 4ca6775d06..2620c893de 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -285,10 +285,10 @@ describe('acp bridge — turn outcomes', () => { const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle - // inject writes turn/start{injection} → context/message → turn/end). Fire + // inject writes turn/start{injection} → user/message → turn/end). Fire // once so it lands between install and the prompt turn. let injected = false - harness.ctx.on('agent/queued', (subject) => { + harness.ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent && !injected) { injected = true agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } }) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 40dbc81acd..5105023581 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -241,7 +241,7 @@ describe('HarnessSdkServer', () => { turn: 2, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } }, }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: 'late metadata' }], source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4a16aa87d0..d473c4ac97 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1487,12 +1487,12 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // Steering messages queued during the running turn (`agent/queued`) that the - // loop has not yet drained, shown as a badge on the status line. Each entry is - // the queued message's serialized source: a drain (`steering/message`) removes - // one MATCHING entry, so loop-authored steering — continuation reasons enter - // the inbox without an `agent/queued` event — cannot consume a pending user - // message's slot. Cleared on leaving `running`, which also absorbs a + // Steering messages queued during the running turn (`agent/inbox/enqueue`) + // that the loop has not yet drained, shown as a badge on the status line. Each + // entry is the queued message's serialized source: a drain (`steering/message`) + // removes one MATCHING entry, so loop-authored steering — continuation reasons + // enter the inbox without an `agent/inbox/enqueue` event — cannot consume a + // pending user message's slot. Cleared on leaving `running`, which also absorbs a // cancellation that discards the queue without logging drains; the status // line exists only while running, so idle carries no badge to keep current. const pendingSteering: string[] = [] @@ -1795,6 +1795,29 @@ export function createTuiChat( const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { switch (event.type) { case 'user/message': { + // Injected context (plugin/goal source) renders as a dim context card, + // not a human bubble; only a direct human prompt is a user message. The + // boolean avoids narrowing `source`, so the label keeps its full union. + const source = event.data.source + if (source.kind !== 'user') { + const references = sessionReferenceCard(event.data.meta) + if (references !== undefined) { + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + break + } + const text = displayText(contentText(event.data.content).trim()) + if (text) { + // The tui type view lacks plugin-augmented source kinds (e.g. goal), + // so read the display label without narrowing on `kind`. + const labelled = source as { kind: string; plugin?: string } + const label = labelled.plugin ?? labelled.kind + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0)) + chat.addChild(new Text(palette.muted(text), 1, 0)) + } + break + } const text = displayText(contentText(displayPromptContent(event.data)).trim()) if (text) { chat.addChild(new Spacer(1)) @@ -1819,22 +1842,6 @@ export function createTuiChat( } break } - case 'context/message': { - const references = sessionReferenceCard(event.data.meta) - if (references !== undefined) { - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) - break - } - const text = displayText(contentText(event.data.content).trim()) - if (text) { - const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0)) - chat.addChild(new Text(palette.muted(text), 1, 0)) - } - break - } case 'prompt/blocked': appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') break @@ -1919,7 +1926,6 @@ export function createTuiChat( const isSurface = event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result' - || event.type === 'context/message' || event.type === 'steering/message' if (isSurface && !active.has(event.seq)) continue if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue @@ -2554,7 +2560,7 @@ export function createTuiChat( // A queued steering message reached the model as it drained; drop its // entry from the badge. Matching by source keeps loop-authored steering // (e.g. continuation reasons), which logs here without a matching - // `agent/queued` increment, from consuming a pending user slot. + // `agent/inbox/enqueue` increment, from consuming a pending user slot. const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) @@ -2568,7 +2574,7 @@ export function createTuiChat( renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) - const disposeQueued = ctx.on('agent/queued', (subject, _content, info) => { + const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return pendingSteering.push(JSON.stringify(info.source)) refreshStatus() diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c6da283236..c8755e4bb3 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -153,6 +153,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e sent.push(content) sentOptions.push(options) }, + followup(content, options) { + sent.push(content) + sentOptions.push(options) + }, steer(content, options) { steered.push(content) steeredOptions.push(options) diff --git a/packages/ui/tui/tests/session-reference.snapshot.ts b/packages/ui/tui/tests/session-reference.snapshot.ts index 4fecad3b86..1671acf594 100644 --- a/packages/ui/tui/tests/session-reference.snapshot.ts +++ b/packages/ui/tui/tests/session-reference.snapshot.ts @@ -128,7 +128,7 @@ describe('TUI session-reference snapshot', () => { type: 'text', text: '\n\n## My request:\n', }) - expect(target.session.events.some(event => event.type === 'context/message')).toBe(false) + expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false) const snapshot = await terminal.snapshot({ includeScrollback: true }) if (REFRESHING) { diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 9a2f3b51e1..1c718a736f 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -451,7 +451,7 @@ describe('TUI terminal-state snapshots', () => { session.append('todo/write', { todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }], }) - session.append('context/message', { + session.append('user/message', { content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }], source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` }, }, { surfaceOp: 'append' }) @@ -567,7 +567,7 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { - harness.session.append('context/message', { + harness.session.append('user/message', { content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }], source: { kind: 'plugin', plugin: 'compact' }, }, { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 22a50c9ccf..667db4abb3 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -374,8 +374,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) @@ -552,16 +552,16 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).not.toContain('queued') const queueSteering = (text: string): void => { - result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, contexts: [], steering: true }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) } const drainSteering = (text: string): void => { result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) } // A steering queue for a different agent never touches this status line. - const other = { ...result.agent, id: SessionId('other') } as Agent + const other = { ...result.agent, id: SessionId('other') } as unknown as Agent result.terminal.output = '' - result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, contexts: [], steering: true }) + result.ctx.emit('agent/inbox/enqueue', other, { content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) await tick() expect(result.terminal.output).not.toContain('queued') @@ -574,7 +574,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A non-steering queue (an idle-style send) leaves the badge untouched. result.terminal.output = '' - result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, contexts: [], steering: false }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) drainSteering('first') await tick() expect(result.terminal.output).toContain('1 queued') @@ -594,7 +594,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A loop-authored steering event (plugin source, no matching agent/queued) + // A loop-authored steering event (plugin source, no matching agent/inbox/enqueue) // cannot consume a pending user slot, even when it drains first. result.terminal.output = '' result.session.append('steering/message', { @@ -627,7 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const idle = await setup() // A steering queue arriving while idle has no status line to badge, so the // refresh is a no-op beyond requesting a render. - idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, contexts: [], steering: true }) + idle.ctx.emit('agent/inbox/enqueue', idle.agent, { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) await tick() expect(idle.terminal.output).not.toContain('Executing tools') @@ -1221,7 +1221,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)') expect(result.terminal.output).not.toContain('hidden non-reference prefix') - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'secret full snapshot payload' }], source: { kind: 'plugin', plugin: 'session-reference' }, meta: { @@ -1240,13 +1240,13 @@ describe('pi-tui chat lifecycle and transcript', () => { [{ kind: 'session-reference', references: [{}] }, 'invalid-fields'], ] for (const [meta, text] of invalidCards) { - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'session-reference' }, meta, }, { surfaceOp: 'append' }) } - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'same-label snapshot' }], source: { kind: 'plugin', plugin: 'session-reference' }, meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] }, @@ -1658,7 +1658,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) - const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } + const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') @@ -2021,7 +2021,7 @@ describe('tool cards and surface replay', () => { turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, }, { surfaceOp: 'append' }) const start = result.session.surface.nodes[0] as number - result.session.append('context/message', { + result.session.append('user/message', { content: [{ type: 'text', text: 'summary replacement' }], source: { kind: 'plugin', plugin: 'compact' }, }, { @@ -2207,7 +2207,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2231,7 +2231,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2265,14 +2265,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2302,7 +2302,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2344,7 +2344,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 40f1a22377..2eee7e0bb0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -36,6 +36,7 @@ export const LINK_MAP: Record<string, string> = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', HookContext: 'core.md', + InboxItemInfo: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmFailure: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c93f70cf81..0c6175d61c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -911,7 +911,7 @@ function renderLifecycle(): string { ' participant Persistence', ' participant SDK as UI or SDK listener', ' User->>Agent: send(content)', - ` Agent-->>SDK: ${mermaidCode('agent/queued')}`, + ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`, ' Agent->>Driver: queued work wakes driver', ` Driver-->>SDK: ${mermaidCode('agent/status')} running`, ` Driver->>Session: ${mermaidCode('turn/start')}`, @@ -988,7 +988,7 @@ function renderToolPipeline(): string { ` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`, ` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`, - ' context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]', + ' context["Active-batch additionalContexts FIFO<br/>injected user/message after recorded tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`, ' allResults["Tool batch settled<br/>recorded tool/result events complete"]', ' presentResult["UI completed card<br/>presentResult(args, result)"]', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index cc85d48ba5..0ddfff5aa6 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -263,7 +263,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-goal', source: 'packages/goal/tool-goal/src/index.ts', requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], - writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'], + writes: ['tool/call', 'user/message goal snapshot for mutations', 'tool/result'], async mount(ctx) { await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) @@ -336,7 +336,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-tasks', source: 'packages/tasks/tool-tasks/src/index.ts', requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'], - writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'], + writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'], async mount(ctx) { await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f95f47e580..5014148504 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -66,6 +66,11 @@ "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SendTarget", + "source": "packages/core/agent/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "SendOptions", @@ -73,12 +78,22 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "AgentCancelCause", + "symbol": "AliasSendOptions", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", - "symbol": "InjectOptions", + "symbol": "InboxItemInfo", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "CancelOptions", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AgentCancelCause", "source": "packages/core/agent/src/types.ts" }, { From 0911a87409a77309b3da82549dfeb7e698f34555 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:29:01 +0800 Subject: [PATCH 035/113] docs(i18n): refresh core translations for latest master --- .../code-runtime.i18n.yaml | 4 +- docs/core-data-structures/code-runtime.zh.md | 54 +++- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.zh.md | 4 +- .../core-data-structures/filesystem.i18n.yaml | 4 +- docs/core-data-structures/filesystem.zh.md | 6 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.zh.md | 26 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.zh.md | 4 +- docs/core-data-structures/tools.i18n.yaml | 4 +- docs/core-data-structures/tools.zh.md | 243 ++++++++++-------- scripts/type-equiv.manifest.json | 52 +++- 13 files changed, 257 insertions(+), 156 deletions(-) diff --git a/docs/core-data-structures/code-runtime.i18n.yaml b/docs/core-data-structures/code-runtime.i18n.yaml index f534e6a821..218ef4eea9 100644 --- a/docs/core-data-structures/code-runtime.i18n.yaml +++ b/docs/core-data-structures/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -code-runtime.md: a984c0f6422defc879086ff95eb94048aaa6e285 -code-runtime.zh.md: 95287f2917c8fd409944569a2f5c3e8417a18efe +code-runtime.md: 64de3c45d4f1d1d981daa6c6f074abb667e0aa52 +code-runtime.zh.md: 4b14aeb2183010e8140540258ce8109df9f59910 diff --git a/docs/core-data-structures/code-runtime.zh.md b/docs/core-data-structures/code-runtime.zh.md index 95287f2917..4b14aeb218 100644 --- a/docs/core-data-structures/code-runtime.zh.md +++ b/docs/core-data-structures/code-runtime.zh.md @@ -2,7 +2,7 @@ [English](code-runtime.md) | 中文 -代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)针对宿主提供的异步 binding 运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread 后端和工具注册表消费方(Code Mode)由 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定。 +代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):其接口([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)针对宿主提供的异步 binding 运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread 后端与工具注册表消费方的契约见 [Code Mode 基础设计](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)和[类型化返回契约](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)。 源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) @@ -47,12 +47,12 @@ interface CodeRunRequest { interface CodeRunResult { /** * The program's completion value (its top-level `return`), when it ran to - * completion and the value survived the runtime's serialization boundary; - * a non-transferable value is replaced by a string rendering, and a failed - * or value-less run leaves this absent. + * completion and the value crossed the runtime's lossless-JSON boundary. + * Invalid or over-limit completions fail the run instead of substituting a + * rendered string; a failed or value-less run leaves this absent. */ - value?: unknown - /** Text the program emitted, in order (capped by the implementation). */ + value?: CodeJsonValue + /** Text the program emitted, in order, bounded only as part of the outer result. */ logs: string[] /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ error?: CodeRunFailure @@ -61,7 +61,23 @@ interface CodeRunResult { ## 绑定:宿主函数作为程序全局变量 -每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(Code Mode 消费方传入一个:`tools`)。参数与返回值必须可 structured-clone(运行时可能跨序列化边界桥接调用),且运行时将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): +每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(Code Mode 消费方传入一个:`tools`)。参数与返回值必须是无损 JSON,且跨越边界时不受 seam 层字节上限约束;运行时可以通过结构化克隆桥接它们。命名空间可以声明程序可见的错误类,而无需让运行时知道消费方的名称:运行时会注入真实构造函数,并将被拒绝的调用转为该类的实例。运行时也将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): + +```ts type-equiv +/** + * Program-visible typed rejection for one binding namespace. The runtime + * injects a real error constructor under `name`; rejected member calls become + * its instances and expose the exact member name through + * `memberNameProperty`. Both strings are runtime data rather than knowledge + * of a particular consumer such as Code Mode. + */ +interface CodeBindingErrorClass { + /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */ + name: string + /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */ + memberNameProperty: string +} +``` ```ts type-equiv /** @@ -76,24 +92,32 @@ interface CodeBindingNamespace { global: string /** The callable members, keyed by the exact name the program calls. */ functions: Record<string, CodeBindingFunction> + /** Optional program-visible typed rejection contract for this namespace. */ + errorClass?: CodeBindingErrorClass } ``` +```ts type-equiv +/** A lossless JSON value transferable across the dependency-light code-runtime seam. */ +type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | { [key: string]: CodeJsonValue } +``` + ```ts type-equiv /** * One host-side function exposed to the program as an async callable. The * runtime bridges calls to it (possibly across a serialization boundary), so - * `args` and the resolution value MUST be structured-cloneable; a runtime - * rejects a non-cloneable value with a descriptive error rather than - * corrupting the run. A rejection of this function surfaces inside the - * program as a rejection of the corresponding call. + * `args` and the resolution value MUST be lossless JSON. A runtime rejects a + * lossy or non-cloneable value with a descriptive error rather than corrupting + * the run. No seam-level byte cap applies to a binding resolution. A rejection + * of this function surfaces inside the program as a rejection of the + * corresponding call. */ -type CodeBindingFunction = (args: unknown) => Promise<unknown> +type CodeBindingFunction = (args: unknown) => Promise<CodeJsonValue> ``` ## 捕获的输出与失败分类体系 -日志是按发出顺序排列的纯字符串。运行时捕获程序的 console 与流输出,但通道和 console 方法的元数据不属于 seam 的一部分,因为消费方只渲染文本。实现对聚合输出设上限,并在输出内标记截断。 +日志是按发出顺序排列的纯字符串。运行时捕获程序的 console 与流输出,但通道和 console 方法的元数据不属于 seam,因为消费方只渲染文本。实现会对序列化后的外层日志数组,以及完成值或失败消息的组合载荷设置上限;固定的结果封装语法与消费方展示空白不计入这份可变载荷计量。超限会显式失败,而不会在值中插入替代内容。 失败类型是**正交的结果,独立报告**(见 [defensive-patterns](../defensive-patterns.md)):预算耗尽不是异常,中止不是超时,基底崩溃(如 OOM)也不是二者中的任何一个: @@ -107,10 +131,12 @@ type CodeBindingFunction = (args: unknown) => Promise<unknown> * - `'timeout'` — an implementation-owned budget expired; the message says which. * - `'abort'` — {@link CodeRunRequest.signal} fired. * - `'worker-exit'` — the execution substrate died without settling (e.g. OOM). + * - `'invalid-output'` — the completion value was not lossless JSON. + * - `'output-limit'` — the serialized outer logs/value/diagnostic exceeded the configured cap. */ interface CodeRunFailure { /** The failure class (see the interface doc for each kind's meaning). */ - kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' + kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit' /** Human-readable detail, suitable for feeding back to a model to self-correct. */ message: string } diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 32019f6002..a01c020939 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -core.md: 7d0f9503dfd4c47f185d2bcef84528145d66fa70 -core.zh.md: 21f417bdf8ddb6c4d629de037d95a0196961aeb3 +core.md: 9446152b909cd3e0105fe44321b16353228c0730 +core.zh.md: 55b04e3ef4215fc984824015761f5f41537656a0 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 21f417bdf8..55b04e3ef4 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -13,7 +13,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** 2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 -其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `SchemaSpec`/`InferArgs` DSL、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 +其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `ValueSchemaSpec`/`ParameterSchemaSpec` 机制、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 | 子页面 | 负责内容 | |---|---| @@ -552,4 +552,4 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' 唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数和可选的 UI 展示器。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的契约。 -其完整字段、`defineTool`/`SchemaSpec`/`InferArgs` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 +其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 6713615776..e360dd99d3 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -filesystem.md: 3c9b041da92e71cd20429e8b1549c0b8e6f2436d -filesystem.zh.md: 612b90427c6813823d38515257acfdfafcc2ba1e +filesystem.md: 110c1fd428b15c5094f9dcc94050cad61c324373 +filesystem.zh.md: aca450364c05c6f756c36fccc11be7246767f3a4 diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 612b90427c..aca450364c 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -207,7 +207,7 @@ interface FsPolicyExec { ## 读取结果(消费方 / 读取渲染) -文本读取受行窗口、字节上限和后端限制约束。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接用 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 +文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接用 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ @@ -216,9 +216,9 @@ interface FileReadOutcome { offset: number /** Returned lines, already numbered. */ lines: FileTextLine[] - /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + /** Exact total line count in the file. */ totalLines: number - /** Whether selected output hit the byte cap before EOF or the requested limit. */ + /** Whether selected output hit the byte cap. */ truncatedByBytes?: true } ``` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 8dbb8a8ae2..ab8a534829 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session.md: e07e38037e51c74886db88d24353266f6035b15a -session.zh.md: 8d3de643a42bdd173528b024276d0f83e90d2d11 +session.md: b342e1c5c3bff030d67c61a6f1daa0c8167182c1 +session.zh.md: 8e8a3f923b2f4ec1dd5d86ebe6bb7eff0511474c diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 8d3de643a4..8e8a3f923b 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -87,15 +87,25 @@ interface SessionEventMap { */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** - * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the - * producing tool owns its shape and reads it back in `presentResult`) but MUST - * be JSON-serializable: `Session.append` runtime-validates all event data with - * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the - * durable log reproduces the identical card on replay. Absent unless the tool - * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + * A completed tool call's model-facing result, optional internal failure + * identity, and optional tool-private `meta` presentation payload. `meta` is + * opaque to the core (the producing tool owns its shape and reads it back in + * `presentResult`) but MUST be JSON-serializable: `Session.append` + * runtime-validates all event data with `isJsonValue`, so a non-serializable + * `meta` is rejected at the source, and the durable log reproduces the + * identical card on replay. Absent + * unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time + * contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } + 'tool/result': { + turn: number + step: number + callId: CallId + content: ContentBlock[] + isError: boolean + error?: { name: string; code: string } + meta?: JsonValue + } /** Steering content injected between steps of a running turn. */ 'steering/message': PromptMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index e0cd108b13..0b6410142f 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -subagent.md: 97d6862c10a0757c41472f207f857c25f3f5d50f -subagent.zh.md: d28e18df6361c4a87a66b027f20cf584b8b14ecb +subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073 +subagent.zh.md: 0f255ac79258d91a3305c4e2a9c9f943f5674c1c diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index d28e18df63..0f255ac792 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -68,11 +68,11 @@ interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** - * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; * a successful child returns the matching value as {@link SubagentResult.structured}. */ - readonly outputSchema?: StructuredOutputSchema + readonly outputSchema?: ObjectJsonSchema /** * Optional absolute delegation-depth cap for the child being started: its * computed depth must be less than or equal to this non-negative safe diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 4f6dd0c8d6..4ef6231caa 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -tools.md: ce14a37da33f89b8b90d6d8e70756f94e3d690dd -tools.zh.md: 77b1c9534c5eae8843e458eedb6af0e8950375ae +tools.md: 4612800ce0b2b2d718cb32c1aad9fb2e592c337b +tools.zh.md: 5663ecef53654c1fc0f8c1d8661ff34cbcd5eb88 diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 77b1c9534c..5663ecef53 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -8,21 +8,36 @@ ## `ToolDefinition` — 一个已注册的工具 -由一个 `ToolSchema`(面向模型的字段)、`execute` 函数、仅供宿主使用的调度器元数据和可选 UI 展示函数组成。注册表持有这些定义,循环通过它们分派调用。注册表的 `schemas()` 通过显式允许列表构建面向模型的 `ToolSchema[]`;`execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。 +由一个 `ToolSchema`(面向模型的字段)、必需的规范输出声明、`execute` 函数、仅供宿主使用的调度器元数据和可选 UI 展示函数组成。注册表持有这些定义,循环通过它们分派调用。注册表的 `schemas()` 通过显式允许列表构建面向模型的 `ToolSchema[]`;`output`/`execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。 + +```ts type-equiv +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection, computed only for surface calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} +``` ```ts type-equiv /** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition /** - * Run one accepted call. Async work must observe or forward `exec.signal` and - * settle only after its owned work reaches quiescence. The registry preserves - * caller cancellation through around-dispatch signal replacement and does - * not abandon this promise, but it cannot hard-kill same-process code. + * Run one accepted call and return only its canonical lossless-JSON value. + * Async work must observe or forward `exec.signal` and settle only after its + * owned work reaches quiescence. The registry preserves caller cancellation + * through around-dispatch signal replacement and does not abandon this + * promise, but it cannot hard-kill same-process code. * @param args - losslessly snapshotted, frozen model arguments. * @param exec - execution identity, cancellation signal, and context deferral. - * @returns model-facing content plus optional private presentation metadata. + * @returns the canonical value declared by `output.schema`. */ - execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> + execute(args: unknown, exec: ToolRunContext): Promise<unknown> /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -57,7 +72,7 @@ interface ToolDefinition extends ToolSchema { presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returns a + * durable result projection (`content`, failure state, and optional `meta`). Returns a * {@link ToolResultView}, or `undefined` (or omit the method) to keep the * pending title and render the raw result content. Pure and side-effect-free * for the same replay reason. @@ -66,69 +81,62 @@ interface ToolDefinition extends ToolSchema { } ``` -`execute` 接收 `args: unknown`——原始的 `ToolDefinition` 自行校验输入。第一方工具不需要手写校验;它们使用 `defineTool`,由后者代为校验并收窄类型。 +`execute` 接收 `args: unknown`——原始的 `ToolDefinition` 自行校验输入。第一方工具不需要手写校验;它们使用 `defineTool`,由后者代为校验并收窄参数类型、根据 `output.schema` 推导函数体返回类型,并为两个输出投影器提供类型约束。 -## 类型化 schema DSL +## 统一的 JSON 值 schema DSL -插件作者为每个属性编写带有布尔值 `required: true` 的规格,类型层面的辅助工具将规格映射为 `execute` 的参数类型——零类型断言。该 DSL 是*提供类型推导的机制*,作用于 `ToolDefinition`;它有意作为子页面细节,而非核心内容。 +插件作者使用同一套词汇描述类型化参数和类型化输出值。`ValueSchemaSpec` 支持 `string`、`number`、`integer`、`boolean`、`null`、`array`、`object`、仅作者侧可用的 `json`,以及要求恰好命中一个分支的 `oneOf`;标量 `enum` 和 `const` 值必须与节点类型匹配。显式对象节点始终声明 `additionalProperties: true | false`。参数定义仍是隐式的开放对象属性映射,每个必填属性都附带 `required: true`。 源码:[`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ```ts type-equiv -/** One schema-spec property entry. */ -interface SchemaProp { - type: SchemaType - /** Per-property required flag (NOT the JSON Schema top-level required array). */ - required?: true - /** Human-readable description, surfaced in the JSON Schema as well. */ - description?: string - /** Enum of allowed values (strings only). */ - enum?: string[] - /** - * Model-visible JSON Schema default annotation. Validation does not apply it; - * dynamic tool mounts may supply it even though first-party definitions do not. - */ - default?: unknown - /** Nested properties for type: 'object'. */ - properties?: SchemaSpec - /** Items schema for type: 'array'. */ - items?: SchemaProp +/** One author-facing schema for any lossless JSON value root. */ +type ValueSchemaSpec = + | StringValueSchemaSpec + | NumberValueSchemaSpec + | IntegerValueSchemaSpec + | BooleanValueSchemaSpec + | NullValueSchemaSpec + | ArrayValueSchemaSpec + | ObjectValueSchemaSpec + | JsonValueSchemaSpec + | OneOfValueSchemaSpec +``` + +```ts type-equiv +/** One implicit parameter-root property, optionally required. */ +type ParameterPropertySpec = ValueSchemaSpec & { required?: true } +``` + +```ts type-equiv +/** + * Tool parameter schema. The map itself is an implicit open object root; + * requiredness remains a per-property `required: true` annotation. + */ +type ParameterSchemaSpec = { + [key: string]: ParameterPropertySpec + [key: symbol]: never } ``` -```ts type-equiv -/** - * The author-facing parameter schema: a shallow map of property name to - * {@link SchemaProp}. Required-ness is a per-property boolean (`required: - * true`), not a separate array. - */ -type SchemaSpec = Record<string, SchemaProp> -``` - -`SchemaType` 是原始联合类型 `'string' | 'number' | 'boolean' | 'object' | 'array'`。`InferArgs<S>` 将一个 `SchemaSpec` 映射为 TS 参数类型——`required: true` 的属性成为必选键,其余为真正的可选: +`{ type: 'json' }` 推导为 `JsonValue`,并编译成仅含注解、不施加约束的原始 schema。输出根可以是对象、数组、标量或 null。`InferValue<S>` 在 16 层容器内保留字面量约束与对象开放性,之后回退为 `JsonValue`,避免耗尽 TypeScript 的类型实例化栈。`InferArgs<P>` 依据逐属性的必填标记生成必填和可选的字符串键: ```ts type-equiv /** - * Infer the TS argument type for a complete {@link SchemaSpec}. - * - * Properties marked `required: true` are required keys; all others are - * genuinely optional keys (`?`), so callers may omit them entirely. - * - * Example: - * ```ts - * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> - * // → { path: string; limit?: number } - * ``` + * Infer the TypeScript value accepted by an author-facing value schema. Exact + * inference is bounded to 16 container levels, then falls back to `JsonValue`. */ -type InferArgs<S extends SchemaSpec> = Simplify< - & { [K in RequiredKeys<S>]: InferPropValue<S[K]> } - & { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> } -> +type InferValue<S> = InferValueAt<S, []> ``` -`defineTool({ name, description, parameters, execute, … })` 将各部分串联:`parameters` 是一个 `SchemaSpec`,`execute(args, exec)` 获得 `args: InferArgs<typeof parameters>`,辅助函数将规格转换为 JSON Schema(`schemaSpecToJsonSchema`)用于协议传输,并在类型化函数体运行前校验模型生成的参数(`validateArgs`)。校验不通过时抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`),注册表将其转为 `isError` 结果以便模型自行修正。为何用自定义 DSL 而非 schemastery:工具参数需要 JSON Schema(LLM(大语言模型)的协议格式),而非校验/转换——轻量 DSL 以最小的接口面积提供最佳的编写体验。 +```ts type-equiv +/** Infer the TypeScript argument object for an implicit parameter schema. */ +type InferArgs<S> = InferProperties<S, []> +``` -注册是一个受信任的同进程契约。注册表以 readonly 输入借用类型化定义,仅校验语义要求(如 `timeoutMs` 必须为正有限值);`schemas()` 在模型边界处物化显式的面向模型投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 +`defineTool({ name, description, parameters, output, execute, … })` 将参数推导与 `parameterSchemaSpecToJsonSchema()` 和 `validateArgs()` 绑定,并将 `execute`/`render`/`presentationMeta` 与 `InferValue<OutputSchema>` 绑定。Schema 记录只包含自有且可枚举的字符串键,schema 数组是稠密的内建数组,因此推导、编译与校验观察到的是同一份声明。精确推导保持到 16 层容器,之后放宽为 `JsonValue`;运行时校验仍会继续遍历完整 schema。`valueSchemaSpecToJsonSchema()` 通过同一套已强制执行的原始子集编译输出声明。参数不匹配时抛出 `ToolArgsError`(`INVALID_ARGS`);函数体或后置策略产生的值无效时抛出 `ToolOutputError`(`INVALID_TOOL_OUTPUT`)。两者都经由常规工具错误路径处理。原始 JSON Schema 默认保持开放;不支持的关键字会被拒绝,而不会在未强制执行的情况下获准进入。 + +注册是一个受信任的同进程契约。注册表以 readonly 输入借用类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在模型边界处物化显式的面向模型投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 ## `ToolRestriction` — 单个作用域的实时全局过滤器 @@ -254,34 +262,48 @@ type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined ``` ```ts type-equiv -/** The outcome of one tool call. */ -interface ToolExecutionResult { - content: ContentBlock[] - isError: boolean - /** - * Set when the call failed with a {@link HarnessError}: machine-routable - * `{ name, code }` for retry/sandbox plugins and replay. The model-facing - * text in `content` is always present; this is extra structure for code. - */ - error?: ToolErrorInfo - /** - * Model-facing context for the next request, separate from this tool result. The loop - * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. - */ - additionalContexts?: HookContext[] - /** - * The tool-private presentation payload from a successful `execute` (the object - * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the - * tool attached none or the call failed. - */ - meta?: unknown +/** Canonical failure detail; internal routing information remains optional. */ +interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo } ``` -结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。 +```ts type-equiv +/** Successful canonical tool execution, including its Native/model projection. */ +interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} +``` -注册表在 `tools/result` 之前立即物化并冻结最终接受的结果。其内容、结构化错误、附加上下文和展示元数据必须通过 JSON 无损往返;无效的产出会被转为 JSON 安全的 `isError` 结果,从而保证被观察到的实时产出对后续持久化的 `tool/result` 追加是安全的。 +```ts type-equiv +/** Failed canonical tool execution; failures never carry a successful value. */ +interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: HookContext[] +} +``` + +```ts type-equiv +/** The discriminated, execution-local outcome of one tool call. */ +type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure +``` + +结果仅承载产出。调用身份保留在不可变的 `ToolExecution` 上,后者伴随结果经过每个钩子,并出现在持久化的 `tool/call` / `tool/result` 会话事件上,因此包装层无法创建第二个相互矛盾的身份。规范的 `value` 仅存在于执行期间:循环只持久化 `content`、`error` 和 `meta`,`tool/code-dispatch` 则存储有界摘要。回放可以重现展示,却无法重建中间值。 + +成功时,注册表会快照并校验函数体返回值,将其冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。注册表会在 `tools/result` 之前另行物化持久展示字段;无效值、渲染器/投影器失败或非 JSON 展示都会转为 JSON 安全的 `isError`。因此,最终实时观察者能看到精确的执行期值,以及可安全用于后续持久追加的字段。 每个拦截 waterfall 返回一个类型化的 **Decision**(与 `agent/*` seam 共享的惯用模式)。`tools/pre-execute` 监听器接收 `(exec, next)` 并返回 `PreToolDecision`;`tools/execute` 包装层返回 `ToolExecutionResult`;`tools/post-execute` 监听器接收 `(exec, result, next)` 并返回 `PostToolDecision`: @@ -300,67 +322,70 @@ type PreToolDecision = ```ts type-equiv /** - * Post-dispatch decision: accept or replace content, attach context for the next - * request, or block by turning corrective feedback into an error result. + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } ``` 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 -后置策略可以替换内容;阻止决策会变为包含纠正反馈的 `isError` 结果。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 +后置策略可以替换内容或值,但不能同时替换两者。替换内容会保留规范值和现有元数据;替换值会重新校验并重新计算内容/元数据;阻止会移除值,并转为包含纠正反馈的 `isError`。内容替换是展示策略,而非保密策略;需要隐藏程序化值的监听器必须阻止或替换该值。`tools/result` 在归一化后接收冻结的执行和结果;观察者无法对其进行变换,观察者的失败也会被隔离。未知工具和抛出异常的工具都会变为结构化错误(`ToolNotFoundError` 映射为 `UNKNOWN_TOOL`),调用失败但不终止当前轮次。 -## 结构化输出 schema 子集 +## 已强制执行的原始 JSON Schema 子集 -调用方用来向 subagent 要求机器可读结果的词汇(`SubagentStartRequest.outputSchema`,见 [subagent.md](subagent.md#the-start-request)),或工作流 `agent()` 调用使用的词汇。它有意不是完整的 JSON Schema:schema 原样传给模型作为强制工具的 `parameters`,产出的值由 `validateStructuredValue` 在客户端校验——因此每个被接受的关键字都必须是校验器实际执行的,`assertSupportedOutputSchema` 会大声拒绝其他任何内容(`OutputSchemaError`,列出所有违规项)。两个遍历器仅推理自有可枚举属性(JSON 不携带其他内容),并拒绝会有损序列化的非纯对象(`Date`、`Map`)。 +subagent、工作流、MCP 和动态注册提供的原始 schema 使用作者侧 DSL 在协议层的对应表示。`assertSupportedJsonSchema()` 接受任意 JSON 根,`validateJsonSchemaValue()` 强制执行该 schema,`JsonSchemaError` 则报告每条不受支持或格式错误的 schema 路径。仅含注解的空节点表示不受约束的无损 JSON。`oneOf` 至少要求两个分支,且一个值必须恰好匹配其中一个。仍要求对象根的消费方调用 `assertObjectJsonSchema()` 并携带 `ObjectJsonSchema`;这样,subagent/工作流中由调用方定义的结构化输出可以继续以对象为根,而不会限制共享词汇。 ```ts type-equiv -/** The scalar values `enum`/`const` may carry (finite numbers only). */ -type StructuredScalar = string | number | boolean | null +/** Scalar JSON values supported by `enum` and `const`. */ +type JsonSchemaScalar = string | number | boolean | null ``` ```ts type-equiv -/** The `type` keywords the subset accepts. */ -type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' +/** Single-type keywords accepted by the enforced subset. */ +type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' ``` ```ts type-equiv /** - * One node of the structured-output schema subset. Recursive via `properties` - * and `items`; see the module doc for the exact keyword semantics. + * One raw JSON Schema node in the enforced subset. The optional fields express + * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * combinations before a caller treats the node as trusted. */ -interface StructuredSchemaNode { - type: StructuredSchemaType +interface JsonSchemaNode { + /** Omit with no constraints for any JSON value, or use `oneOf`. */ + type?: JsonSchemaType + /** Exactly one branch must validate; at least two branches are required. */ + oneOf?: JsonSchemaNode[] /** Nested property schemas (`type: 'object'` only). */ - properties?: Record<string, StructuredSchemaNode> + properties?: Record<string, JsonSchemaNode> /** Required property names; each must appear in `properties`. */ required?: string[] - /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ + /** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */ additionalProperties?: boolean - /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ - items?: StructuredSchemaNode - /** Allowed values (scalar types only). */ - enum?: StructuredScalar[] - /** The single allowed value (scalar types only). */ - const?: StructuredScalar + /** Item schema (`type: 'array'` only); absent accepts any JSON item. */ + items?: JsonSchemaNode + /** Allowed values for a scalar node. */ + enum?: JsonSchemaScalar[] + /** The single allowed value for a scalar node. */ + const?: JsonSchemaScalar /** Annotation, ignored for validation. */ description?: string /** Annotation, ignored for validation. */ title?: string - /** Annotation, ignored for validation (must still be JSON data). */ - default?: unknown - /** Annotation, ignored for validation (must still be JSON data). */ - examples?: unknown + /** Annotation, ignored for validation but required to be lossless JSON. */ + default?: JsonValue + /** Annotation, ignored for validation but required to be lossless JSON. */ + examples?: JsonValue } ``` -schema 是一个以 object 为根的节点(`enum`/`const` 仅限标量;`description`/`title`/`default`/`examples` 是注解,允许但忽略,但仍要求为 JSON 数据——它们随协议传输): - ```ts type-equiv -/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ -type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } +/** A consumer-constrained object-rooted schema. */ +type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } ``` ## 工具展示 UI 词汇 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b7970091fe..c97162ec29 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1487,6 +1487,11 @@ "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolOutputDefinition", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolDefinition", @@ -1494,12 +1499,22 @@ }, { "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "SchemaProp", + "symbol": "ValueSchemaSpec", "source": "packages/core/tools/src/schema.ts" }, { "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "SchemaSpec", + "symbol": "ParameterPropertySpec", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ParameterSchemaSpec", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "InferValue", "source": "packages/core/tools/src/schema.ts" }, { @@ -1547,6 +1562,21 @@ "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolFailure", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolExecutionSuccess", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "ToolExecutionFailure", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolExecutionResult", @@ -1564,22 +1594,22 @@ }, { "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "StructuredScalar", + "symbol": "JsonSchemaScalar", "source": "packages/core/tools/src/json-schema.ts" }, { "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "StructuredSchemaType", + "symbol": "JsonSchemaType", "source": "packages/core/tools/src/json-schema.ts" }, { "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "StructuredSchemaNode", + "symbol": "JsonSchemaNode", "source": "packages/core/tools/src/json-schema.ts" }, { "doc": "docs/core-data-structures/tools.zh.md", - "symbol": "StructuredOutputSchema", + "symbol": "ObjectJsonSchema", "source": "packages/core/tools/src/json-schema.ts" }, { @@ -1717,6 +1747,11 @@ "symbol": "ConfinedArgv", "source": "packages/sandbox/sandbox/src/index.ts" }, + { + "doc": "docs/core-data-structures/code-runtime.zh.md", + "symbol": "CodeJsonValue", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, { "doc": "docs/core-data-structures/code-runtime.zh.md", "symbol": "CodeRunRequest", @@ -1732,6 +1767,11 @@ "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" }, + { + "doc": "docs/core-data-structures/code-runtime.zh.md", + "symbol": "CodeBindingErrorClass", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, { "doc": "docs/core-data-structures/code-runtime.zh.md", "symbol": "CodeBindingFunction", From 95e75ba3e05636741f1a8508a8fa3d0f49f2a70f Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 19:37:21 +0800 Subject: [PATCH 036/113] fix(agent-loop): balance inbox invariant on continuation-reason steer The FIFO-conservation invariant fired on the loop-authored continuation reason path: a continue-with-reason decision entered the steering FIFO without an agent/inbox/enqueue, so its later dequeue/discard had no matching enqueue. Emit the enqueue for that steer too, add a regression test that mounts the invariant over a continue-with-reason turn and a cancel, and hoist the duplicated inboxInfo helper into inbox.ts. Found by fresh-eye review. --- ...nified-send-and-coalesced-user-messages.md | 2 +- packages/core/agent-loop/src/agent.ts | 13 +-- packages/core/agent-loop/src/inbox.ts | 12 ++- packages/core/agent-loop/src/loop.ts | 17 ++-- .../agent-loop/tests/inbox-invariant.spec.ts | 89 +++++++++++++++++++ packages/ui/tui/src/index.ts | 23 ++--- packages/ui/tui/tests/tui.spec.ts | 5 +- 7 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 packages/core/agent-loop/tests/inbox-invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 0dfa875d0b..3bf721f0fd 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -18,7 +18,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. **cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index d118fb3063..670945a8fa 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -9,12 +9,12 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' import { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, InboxItemInfo, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' -import { Inbox, type InboxMessage } from './inbox.ts' +import { Inbox, inboxInfo, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' /** Sessions already claimed by a concrete driver construction. */ @@ -205,11 +205,6 @@ export class ReactLoopAgent extends Agent { return deepFreeze(accepted) } - /** Build the `agent/inbox/*` payload for one accepted item. */ - private inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { - return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } - } - /** Detach one context before it can outlive its caller in the active-batch FIFO. */ private acceptContext(context: HookContext): HookContext { const accepted = snapshotJsonValue(context) @@ -240,7 +235,7 @@ export class ReactLoopAgent extends Agent { } else { this.#inbox.enqueue(accepted, wakeup) } - agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', this.inboxInfo(accepted, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', inboxInfo(accepted, steering)) } /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ @@ -351,7 +346,7 @@ export class ReactLoopAgent extends Agent { // Clear work already present before abort observers run. this.#inbox.clear() if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => this.inboxInfo(message, steering)) + const items = discarded.map(({ message, steering }) => inboxInfo(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index a5ddd87b1f..e6bbbcfb7f 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -7,7 +7,7 @@ */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { HookContext } from '@deepseek-ai/dsh-agent' +import type { HookContext, InboxItemInfo } from '@deepseek-ai/dsh-agent' /** One message waiting in an agent's inbox. */ export interface InboxMessage { @@ -18,6 +18,16 @@ export interface InboxMessage { wakeup: boolean } +/** + * Build the `agent/inbox/*` event payload for one inbox item. + * @param message - the accepted inbox record. + * @param steering - whether the item is in the steering FIFO (`next-step`). + * @returns the live-event facts for enqueue/dequeue/discard. + */ +export function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { + return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } +} + /** * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index c13fa5a74f..11733b17e8 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,7 +10,7 @@ import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFai import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, InboxItemInfo, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -19,14 +19,9 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' -import type { Inbox, InboxMessage } from './inbox.ts' +import { inboxInfo, type Inbox, type InboxMessage } from './inbox.ts' import type { TurnCancellation } from './cancellation.ts' -/** Build the `agent/inbox/dequeue` payload for one claimed inbox item. */ -function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { - return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } -} - /** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): RequestError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) @@ -543,9 +538,13 @@ async function runTurn( break } - // A continuation reason becomes next-step steering. + // A continuation reason becomes next-step steering. Publish the same + // enqueue event a public steer would, so the inbox ledger stays balanced + // (every FIFO entry has a matching enqueue before its dequeue/discard). if (decision.action === 'continue' && decision.reason) { - handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true }) + const item: InboxMessage = { content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true } + handle.inbox.steer(item) + events.emit('agent/inbox/enqueue', inboxInfo(item, true)) } let shouldContinue = decision.action === 'continue' diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts new file mode 100644 index 0000000000..ac84c3d157 --- /dev/null +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -0,0 +1,89 @@ +/** + * Regression: the dsh-agent FIFO-conservation invariant must stay balanced on + * the loop-authored continuation-reason steering path. A continue-with-reason + * decision enters the steering FIFO and later drains (or is discarded by + * cancel); both must be matched by an enqueue event so the invariant's + * outstanding count never goes negative. + * @module dsh-agent-loop/tests/inbox-invariant + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentInvariant) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise<void> { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +describe('inbox FIFO-conservation invariant', () => { + it('stays balanced when a continuation reason enters and drains the steering FIFO', async () => { + const adapter = new MockAdapter([textResponse('step 1'), textResponse('step 2')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + let forced = false + ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next) => { + if (forced) return next() + forced = true + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(2) + // The continuation reason drained as a steering/message on the second step. + expect(agent.session.events.some(e => e.type === 'steering/message')).toBe(true) + // No invariant violation was logged. + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) + + it('stays balanced when cancel discards a pending continuation reason', async () => { + const adapter = new MockAdapter([textResponse('only step')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // Force a continuation reason, then cancel from the same checkpoint so the + // reason sits in the steering FIFO when the inbox is discarded. + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { + if (subject !== agent) return next() + queueMicrotask(() => { agent.cancel({ kind: 'user' }) }) + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) +}) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d473c4ac97..d493c26a9c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1487,14 +1487,15 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // Steering messages queued during the running turn (`agent/inbox/enqueue`) - // that the loop has not yet drained, shown as a badge on the status line. Each - // entry is the queued message's serialized source: a drain (`steering/message`) - // removes one MATCHING entry, so loop-authored steering — continuation reasons - // enter the inbox without an `agent/inbox/enqueue` event — cannot consume a - // pending user message's slot. Cleared on leaving `running`, which also absorbs a - // cancellation that discards the queue without logging drains; the status - // line exists only while running, so idle carries no badge to keep current. + // Steering messages queued during the running turn (`agent/inbox/enqueue` + // with `info.steering`) that the loop has not yet drained, shown as a badge on + // the status line. Each entry is the queued message's serialized source: a + // drain (`steering/message`) removes one MATCHING entry, so a loop-authored + // continuation reason (which enqueues and drains under its own source) pushes + // and pops its own slot and cannot consume a pending user message's slot. + // Cleared on leaving `running`, which also absorbs a cancellation that + // discards the queue without logging drains; the status line exists only + // while running, so idle carries no badge to keep current. const pendingSteering: string[] = [] let disposed = false let shuttingDown: Promise<void> | undefined @@ -2558,9 +2559,9 @@ export function createTuiChat( advanceTurnPhase(event) if (event.type === 'steering/message') { // A queued steering message reached the model as it drained; drop its - // entry from the badge. Matching by source keeps loop-authored steering - // (e.g. continuation reasons), which logs here without a matching - // `agent/inbox/enqueue` increment, from consuming a pending user slot. + // entry from the badge. Matching by source keeps a loop-authored + // continuation reason popping its own enqueued slot rather than a pending + // user message's slot. const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 667db4abb3..b45b7a826f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -594,8 +594,9 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A loop-authored steering event (plugin source, no matching agent/inbox/enqueue) - // cannot consume a pending user slot, even when it drains first. + // A steering/message whose source matches no pending badge entry (here a + // plugin source with no tracked enqueue) pops nothing, so it cannot consume + // a pending user slot even when it drains first. result.terminal.output = '' result.session.append('steering/message', { turn: 1, From 86940da52413d23564880163f5c6aabe0e77e6b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:48:00 +0800 Subject: [PATCH 037/113] docs(i18n): refresh Agent Notes after parent merge --- .../2026-06-11-custom-schema-dsl.i18n.yaml | 4 ++-- .../2026-06-11-custom-schema-dsl.zh.md | 4 ++-- ...026-06-11-runtime-arg-validation.i18n.yaml | 4 ++-- .../2026-06-11-runtime-arg-validation.zh.md | 6 ++--- ...06-17-filesystem-capability-seam.i18n.yaml | 4 ++-- ...026-06-17-filesystem-capability-seam.zh.md | 2 +- ...eneric-long-running-tool-runtime.i18n.yaml | 4 ++-- ...20-generic-long-running-tool-runtime.zh.md | 2 ++ ...2-result-time-applied-hunk-diffs.i18n.yaml | 4 ++-- ...07-02-result-time-applied-hunk-diffs.zh.md | 16 +++++-------- ...6-07-07-tool-call-timeout-policy.i18n.yaml | 4 ++-- .../2026-07-07-tool-call-timeout-policy.zh.md | 5 +++- .../feature/2026-06-15-code-mode.i18n.yaml | 4 ++-- .../feature/2026-06-15-code-mode.zh.md | 23 ++++++++++--------- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 4 ++-- .../2026-06-17-filesystem-tool-schemas.zh.md | 2 +- .../2026-06-30-interception-seams.i18n.yaml | 4 ++-- .../2026-06-30-interception-seams.zh.md | 6 ++--- .../2026-07-05-dynamic-workflows.i18n.yaml | 4 ++-- .../2026-07-05-dynamic-workflows.zh.md | 4 ++-- .../feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .../feature/2026-07-06-sandbox.zh.md | 2 +- ...-self-referential-cordis-toolset.i18n.yaml | 4 ++-- ...7-08-self-referential-cordis-toolset.zh.md | 6 ++--- ...-20-core-data-structures-catalog.i18n.yaml | 4 ++-- ...6-06-20-core-data-structures-catalog.zh.md | 2 +- ...026-06-11-property-based-testing.i18n.yaml | 4 ++-- .../2026-06-11-property-based-testing.zh.md | 2 +- ...nimplemented-subagent-vocabulary.i18n.yaml | 4 ++-- ...ne-unimplemented-subagent-vocabulary.zh.md | 2 +- 30 files changed, 73 insertions(+), 71 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml index cf2b48bfdb..172b94720d 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-custom-schema-dsl.md: 9cef4175e4c4a2e6d2ea034d3522d8a8ddd1f5cb -2026-06-11-custom-schema-dsl.zh.md: 8b1241edea4c591e428fbaa15916437a3de42061 +2026-06-11-custom-schema-dsl.md: 947d53555df078bfa9f3dac48eab4b8c0074007c +2026-06-11-custom-schema-dsl.zh.md: 7317e950a4d9822edfca190ccfc22b65809f078a diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md index 8b1241edea..7317e950a4 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -在 dsh-tools 中实现一个小型自定义 DSL:`SchemaSpec`(逐属性规格,带 `required: true` 布尔值);类型层面的 `InferArgs<S>` 将规格映射为参数类型(required 键为必选,其余通过 `?` 标记为真正可选);运行时的 `schemaSpecToJsonSchema()` 转换器;以及将三者串联的 `defineTool()`。`ToolRegistry.register()` 仍然接受原始 JSON Schema 的 `ToolDefinition`——MCP 来源的工具正是以此方式注册。 +该决策已由[统一 JSON 值 schema DSL](2026-07-20-unified-json-value-schema-dsl.md)取代;新设计保留小型编写接口,同时让参数与类型化值共享一套词汇。`ParameterSchemaSpec` 保留逐属性的 `required: true`;`InferArgs<S>` 将必需键映射为非可选属性;`parameterSchemaSpecToJsonSchema()` 编译隐式开放的对象根;`defineTool()` 则将类型推导、编译与校验串联起来。原始 JSON Schema 的 `ToolDefinition` 仍是 `ToolRegistry.register()` 接受的输入,供 MCP 和其他外部工具使用。 ## 曾考虑的替代方案 @@ -19,5 +19,5 @@ Status: implemented ## 后果 - 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。 -- DSL 有意保持小巧(string/number/boolean/object/array、enum、default、嵌套 properties/items)。相对完整 JSON Schema 的缺口(union、format、constraint)在真实工具提出需求之前暂不补齐。 +- 当前节点、字面量约束、联合类型、JSON 值边界与对象开放性规则均由上述统一说明定义。 - `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index 46b8dad11a..0697332171 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-runtime-arg-validation.md: 6ac2a2ab475c9813dbfbb09e45da71821489d6cd -2026-06-11-runtime-arg-validation.zh.md: 119f738dbb3aaee4786aa58d5495ab5e012e206e +2026-06-11-runtime-arg-validation.md: e0bca0ff24c5adc7ca58007932dff6580694b01d +2026-06-11-runtime-arg-validation.zh.md: 09958147766b4015d6bebf786c4947b9d2941f74 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index 119f738dbb..0995814776 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -`defineTool`([自定义 schema DSL](2026-06-11-custom-schema-dsl.md))通过 `InferArgs<S>` 映射为工具作者提供了类型化的 `execute(args)`。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串、枚举值超出集合)会以「仅名义类型化」的状态到达 `execute`。工具函数体要么在错误形状上崩溃(产生模型无法据以自我修正的通用堆栈跟踪),要么更糟——静默地行为异常。与此同时,转换器已经编码了校验器遍历所需的完整结构。 +`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.md))为工具作者的 `execute(args)` 提供了经 `InferArgs<S>` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅名义类型化」的状态到达 `execute`。工具函数体随后要么在错误形状上崩溃,要么静默地行为异常。 ## 决策 -`validateArgs(spec, args): string[]` 对运行时值解释一个 `SchemaSpec`,返回可读的违规列表(空数组 = 合法),且是全函数(永不抛出异常)。`defineTool` 在调用类型化函数体之前运行它;存在违规时抛出 `ToolArgsError`(`code: 'INVALID_ARGS'`,消息中列出违规项),注册表既有的 execute-waterfall(瀑布式事件)catch 将其转为模型可读取并据以自我修正的 `isError` 结果。 +`validateArgs(spec, args): string[]` 编译 `ParameterSchemaSpec`,并委托共享的 `validateJsonSchemaValue()` 遍历器,对格式正确的声明返回可读的违规列表。`defineTool` 在定义时对编译后的参数 schema 创建快照,并在调用类型化函数体之前执行校验;存在违规时会抛出 `ToolArgsError`(`INVALID_ARGS`),注册表将其作为模型可据以修正的错误结果返回。 -校验器严格镜像 `schemaSpecToJsonSchema` 的语义——遍历相同结构、执行相同规则:顶层必须是非数组对象;必需键仅来自 `required: true`;允许额外键(不设 `additionalProperties: false`);不应用 `default`;没有 `properties`/`items` 的 `object`/`array` 属性仅做类型检查;`enum` 是成员资格检查。原始注册的(MCP)工具不受影响——它们自行校验输入。 +校验器与编译器因此共享完全一致的语义:隐式参数根是开放对象;必需键仅来自 `required: true`;默认值仍是注解;显式嵌套对象遵循其声明的开放性;数组通过 `items` 递归;标量字面量约束保证类型正确;`oneOf` 仅在恰好一个分支匹配时才接受。原始注册的工具自行负责输入校验。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index e0fd8d2d7b..28b78d932e 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-filesystem-capability-seam.md: cd8f8572730b1833ed1d7dbe1861d516c2d32925 -2026-06-17-filesystem-capability-seam.zh.md: fa06ecc4538258163ae17cd403d9e856c4d2911f +2026-06-17-filesystem-capability-seam.md: 08e8d52b314eb10e2c7ec444dd61a96d8621e032 +2026-06-17-filesystem-capability-seam.zh.md: 8ad3b55b6887a2951211a6756670764bb8e820f5 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index fa06ecc453..8ad3b55b68 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -124,7 +124,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` ## 测试 -测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,而非信任返回的 `ContentBlock[]`。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。 +测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam;`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,既不信任规范值,也不信任渲染内容。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。 本仓库曾踩过的防御性模式类别被直接固定: diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index af587f2c25..fab21ef46b 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-generic-long-running-tool-runtime.md: c28236d194ae839f88d900f0fc52707b2c9e53e2 -2026-06-20-generic-long-running-tool-runtime.zh.md: 911c4288fff6e4235b9c88efeaaa882910e559b0 +2026-06-20-generic-long-running-tool-runtime.md: 272cdaf91b35b30c125aee391d5e09238155463a +2026-06-20-generic-long-running-tool-runtime.zh.md: 9a427199dfd1bafc4bfe6d7db5b84efc66b24ceb diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 911c4288ff..9a427199df 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -25,6 +25,8 @@ Status: implemented 字面类型见[任务数据结构目录](../../../../docs/core-data-structures/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。 +面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', taskId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`task_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。 + 生产方钩子定义三项职责: - `cancel(reason?)` 同步请求终止,具备幂等性,并且必须使 `done` 完成。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml index 999b2cbd85..632ba388a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-result-time-applied-hunk-diffs.md: b1e1884f2264f17f6fff17569d79d8352e3fc709 -2026-07-02-result-time-applied-hunk-diffs.zh.md: f64d984df524f25f041a56b1f0bc287e1e4b77a6 +2026-07-02-result-time-applied-hunk-diffs.md: fe163484ad56858fabd55dacace1d56814bd93e4 +2026-07-02-result-time-applied-hunk-diffs.zh.md: e8283a40d96c031a84e2627117801b3043eb8904 diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md index f64d984df5..e8283a40d9 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md @@ -16,24 +16,20 @@ Status: implemented 添加一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来携带 applied-hunk diff。 -### 1. 工具结果上的 `meta` 通道(core) +### 1. 规范工具输出上的可回放展示投影(core) -`ToolDefinition.execute` 现在可以返回其面向模型的 `ContentBlock[]`(不变,常见情况)或 `{ content: ContentBlock[]; meta?: unknown }`: +原始实现允许 `execute` 返回 `{ content, meta }`。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)取代了这种编写形态:每个工具如今返回一个由 schema 声明的 JSON 值,`output.render(args, value)` 从中派生面向模型的内容块,可选的 `output.presentationMeta(args, value)` 则派生可回放的 UI 数据。 -```ts ignore-check -type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } -``` +`presentationMeta` 是工具自有的 `JsonValue`,core 会持久化它,但不解释其中的字段。`Session.append` 将它与事件的其余部分一并校验,回放再把存储的载荷传回 `presentResult`;因此展示无需 I/O 或重新计算即可复现。规范值本身只存在于执行期间,不会加入会话格式。 -`meta` 是工具自有的 `unknown`,core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回传给 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。 - -这是通用形态(「工具附加持久化的结果展示」),而非 fs 特有的——任何工具都可以使用。 +这仍是通用形态(「工具投影持久化的结果展示」),而非 fs 特有;任何工具都可以使用。 ### 2. 工具计算 hunk;后端返回 before/after(fs) 按照 [capability-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**: - `dsh-fs` 将 `FsEditOutcome` 扩展为包含 `{ before: string; after: string }`,将 `FsWriteOutcome` 扩展为包含 `{ before: string | null; after: string }`(`before: null` 表示创建,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。 -- `dsh-tool-fs` 将上下文 hunk 存入 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换待定卡片:创建或无变化的覆写回退到由参数推导的整文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。 +- `dsh-tool-fs` 返回规范的变更前/后事实,并将上下文 hunk 投影为 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换待定卡片:创建或无变化的覆写回退到由参数推导的整文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。 ### 3. Bridge 渲染 `diff` 结果卡片 @@ -45,7 +41,7 @@ type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unkn ## 后果 -`tool/result` 事件现在可以携带工具私有的 `meta` 载荷——属于磁盘格式词汇的一部分,由 `Session.append` 在运行时限制为 JSON——任何工具都可以附加持久化的结果展示而无需再改 core。diff 卡片在会话重载和快照回放时免费复现:它从日志中读回,从不重新计算。代价:覆写操作在内存中同时持有旧文本和新文本以计算仅用于 UI 的 hunk(`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。 +`tool/result` 事件携带工具私有的 `meta` 载荷;它属于磁盘格式词汇的一部分,由 `Session.append` 在运行时限制为 JSON。任何工具都可以投影持久化的结果展示,无需再改 core。diff 卡片在会话重载和快照回放时免费复现:它从日志中读回,从不重新计算。代价:覆写操作在内存中同时持有旧文本和新文本以计算仅用于 UI 的 hunk(`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。 ## 非目标 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index f98cd104aa..be5d4269fb 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-07-tool-call-timeout-policy.md: 538225b197f7dd62b4798c037b72bf3cfc40648a -2026-07-07-tool-call-timeout-policy.zh.md: e37c8099a7780a8aa44f3154ceaf7ffa772f1e3b +2026-07-07-tool-call-timeout-policy.md: 69fd1ee721de69621d3b57c10d960da0651b94dd +2026-07-07-tool-call-timeout-policy.zh.md: 8a92236b1b240e92785e2e7107bf9c7786906627 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index e37c8099a7..8a92236b1b 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -63,7 +63,10 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { return { content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], isError: true, - error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + error: { + message: `tool call timed out after ${timeoutMs}ms`, + info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + }, } } ``` diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 3ac7ec65d0..28fd6c197c 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-15-code-mode.md: f403e87601aad0fda5d53ec5b94ba44452e79b49 -2026-06-15-code-mode.zh.md: c119bcfc5edb3d94f80390a9c16bcb6246500ed0 +2026-06-15-code-mode.md: 52f8546f57e82071ff27983f997e21f29683bbb3 +2026-06-15-code-mode.zh.md: 3fcf8e4caa152fc2159f6df56b6c61a677de690d diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index c119bcfc5e..3fcf8e4caa 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -22,6 +22,8 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 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 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 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 约定。 @@ -40,15 +42,15 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: -1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定对参数做 JSON 规范化——在分发前拒绝有损值——等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch`。成功的文本变为字符串,非文本块变为占位符;工具错误使绑定 promise reject。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 +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 未完成的工作并排空分发队列后再返回。成功时返回捕获的输出和呈现元数据。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 +3. **静默后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 **子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute block 会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 **并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。 -**呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定:`presentCall` → 一个 `generic` 卡片,`kind: 'execute'`,title = 程序文本,`rawInput` = 同一程序文本;`presentResult` → 一个 `generic` 卡片,content 为捕获的输出(来自 `meta`)。程序作为 title 是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。 +**呈现。** `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` @@ -59,10 +61,9 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 `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<unknown>> }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;绑定参数和解析值必须是 structured-cloneable 的(运行时可能跨越序列化边界;我们的实现确实如此)。 -- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }`——程序执行结果,包括异常、超时、abort 和 worker 退出,都解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝。 -- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` -- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时。 +- `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 }`——程序执行结果解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 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 生成器。 @@ -73,9 +74,9 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 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` 的函数体,其参数是绑定全局变量和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用,程序的完成值即为 run 的 `value`(structured-cloneable 值原样跨越;其他值被替换为其 `util.inspect` 渲染,已文档化)。 +3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用。Code Mode 声明 `ToolCallError`,成员属性为 `toolName`;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;`undefined` 仍表示缺席,有损值产生 `invalid-output`,过大的外层结果产生 `output-limit`,而不会退化为检查格式化后的字符串替代品。 4. **通过 message port 桥接绑定**: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` 约束总经过时间,包括未解析的等待。到期、取消和完成都终止 worker。堆退出和截断被显式报告;compute、wall、heap、log 和返回值上限是经校验的配置。 +5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。`maxOutputBytes` 只约束序列化后的外层日志、完成值或诊断的组合;中间绑定值没有字节数上限。到期、取消和完成都终止 worker,堆退出或外层溢出会作为显式失败报告。 6. **dispose 至静默**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.md)。 ### 信任姿态 @@ -92,7 +93,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw ## 测试 -- **Worker 运行时:** 真实 worker 测试覆盖输出和值捕获、失败类型、compute 和 wall 预算、恶意绑定流量、空环境、structured-clone 回退、输出上限和 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。 +- **Worker 运行时:** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。 - **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层 block 抑制以及 HMR(热模块替换)清理。 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 - **快照:** `code-mode-turn`、`both-mode-turn` 和 `code-mode-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 @@ -125,7 +126,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw **注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 之后。 -**Structured-clone 值可能超出 JSON。** 因此工具绑定在分发前对参数做 JSON 规范化,确保每次执行的调用都可记录。底层运行时保持其更宽的端口契约,而更严格的消费方在自己的边界处校验。非文本子结果变为占位符。 +**大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 解析值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 **仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index c3ef031858..60a0b50006 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-17-filesystem-tool-schemas.md: fa23dccc7ae98a9c25b9c474a75dc1e2f8e5ff21 -2026-06-17-filesystem-tool-schemas.zh.md: 79f172083fd2827eaa84489550577e10fd98c114 +2026-06-17-filesystem-tool-schemas.md: 9941b3916b361a916c8148eb099eb8cfd46371c8 +2026-06-17-filesystem-tool-schemas.zh.md: 47e43c47db83b1fcc292b17cf0113d9abd9293d1 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index 79f172083f..47e43c47db 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -70,7 +70,7 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面 ## 结果形状 -首次实现通过现有的 `ToolDefinition.execute()` 契约返回 `ContentBlock[]`。`ctx.fs` 返回结构化的文件系统结果并负责文件状态的记录/刷新;`tool-fs` 将这些结果格式化为模型投影。 +首次实现曾将 `ContentBlock[]` 格式化逻辑放在 `execute` 中。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)如今将 `ctx.fs` 的结果事实保留为工具经校验的值,并通过 `output.render` 派生相同的模型文本;文件状态的记录/刷新仍归 `ctx.fs` 所有。 默认原生投影: diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index 14d8460137..294bfc59a0 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-interception-seams.md: af78672c8c6426aa3992355b23b191c3409dcac2 -2026-06-30-interception-seams.zh.md: fe2702c5b74aa4b1822a5506f06557cae5af88a2 +2026-06-30-interception-seams.md: 5afb080857a84544b0b3c9ac5aa8c4efc053c272 +2026-06-30-interception-seams.zh.md: 233225ce687cf23402718e67b1c73f9dda4149af diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index fe2702c5b7..233225ce68 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -26,11 +26,11 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 - **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 与核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每种结果仍会到达后策略与最终观测者。 - **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的、作用域感知的策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。 -- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前可以替换并恢复必需的 `exec.signal`,但不能移除它;包装层接收已规范化的抛出或未知工具结果,返回自己的有效结果则短路调度。 -- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、可选地替换内容,或附加 `additionalContexts`。返回的 decision 是受支持的变换通道;waterfall 结束后,注册表会在最终观测前一次性实体化完整结果。 +- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前可以替换并恢复必需的 `exec.signal`,但不能移除它;包装层接收抛出异常或未知工具产生的、已完成规范化的规范成功/失败结果。包装层自行产生的成功结果会短路调度,并通过已解析的输出声明重新规范化。 +- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、替换呈现内容或规范值,或附加 `additionalContexts`。替换值会重新校验并重新计算呈现;替换内容会保留程序化值,且不构成保密边界。返回的 decision 是受支持的变换通道;waterfall 结束后,注册表会在最终观测前一次性实体化完整结果。 - **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步封闭通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。 -核心调度与工具体位于规范化边界内部,因此工具、监听器、格式错误的结果、非 JSON 结果和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具,最终观测者看到的正是调用方收到的、会话日志可以持久化的内容。 +核心调度与工具体位于规范化边界内部,因此工具、监听器、无效规范值、渲染器/投影器、非 JSON 呈现和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具,最终观测者会同时看到执行期间的规范值,以及会话日志能够持久化的确切呈现字段。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)定义值/投影与持久性规则。 **`TurnEndReason.rejected`**(`dsh-session`):取得所有权的 prompt 被 `prompt-submit` 阻止的零步骤轮次。 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index 8f4da1a9fc..a7fdfbc9d7 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-05-dynamic-workflows.md: 353ab56aaac2d0d7624ff35f03cc7073e50a1a7d -2026-07-05-dynamic-workflows.zh.md: 7919fe12d1f1342738dafd8378c8c7f1dc932c0d +2026-07-05-dynamic-workflows.md: 4c8606fb617b3fb6b2e8ad9c35d77c585b1fe8b1 +2026-07-05-dynamic-workflows.zh.md: 2405e6e6746569e9327f9eb5ecbb8b37e5ef1bee diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 7919fe12d1..2405e6e674 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -46,7 +46,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) 输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误;没有已提交捕获的正常完成以错误结算。 -`StructuredOutputSchema` 是 `dsh-tools` 中可强制执行的原始 JSON-Schema 子集(单字符串 `type`、`properties`/`required`/`additionalProperties`、`items`、标量 `enum`/`const`),不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。组装、提交、守卫和终止停止的正确性算法归[agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes)所有。 +`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)则定义组装、提交、守卫和终止停止算法。 ## 测试 @@ -70,7 +70,7 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 - **后台执行作为默认**(CC 的形态):延迟。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash/subagent/workflow 之间统一设计一次,而非逐工具设计。 - **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 关注点,而 seam 的能力标志仍不诚实地为 `false`。 - **Meta 嵌入脚本中作为 `export const meta = {...}`**(CC 的确切格式):保持脚本自包含且 CC 脚本可直接使用,但获取 meta 需要在宿主上执行模型编写的文本。即使一个空的限时 vm 上下文也无法约束脚本控制的 getter(当宿主读取结果对象时)。JSON 参数消除了扫描器、执行和宿主自旋漏洞;代价是 CC 脚本的 meta 头必须移入参数(正文保持可直接使用)。 -- **`SchemaSpec` 作为 outputSchema 类型**:面向作者的 DSL 无法表达以数据形式到达的内容,也无法在不丢失转换精度的情况下对其进行校验。 +- **`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 后续可以在不改变本设计的情况下收窄接受的子集。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 7ec0fdd58d..bee8c40ef0 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-sandbox.md: 738a1796b047561b861fc237154210659515bd6f -2026-07-06-sandbox.zh.md: 99ce35885ba5fa5367c4b61aa08357129d972885 +2026-07-06-sandbox.md: c1307a7201ed1bc331a86d4ffee002d69fd1d5e5 +2026-07-06-sandbox.zh.md: 9e0c1615d02fed89ef0cda2a111e36385ad00497 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 99ce35885b..9e0c1615d0 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -119,7 +119,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、permission preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。 - **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定 permission 切换并拒绝未知 preset。CI 拒绝静默全跳过。 -- **With-key:** 驱动真实模型、runner、bridge 应答器和磁盘效果通过授权和拒绝的升级;不可用的凭证或 runner 自动跳过。 +- **With-key:** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。 - **快照:** 固定 permission config-option 协议格式(wire format)、preset 和旋钮事件、prompt delta 和通知、以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。 ## 延迟阶段 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index f7e6c2af07..8803b75623 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-08-self-referential-cordis-toolset.md: 73d394c24100af8f506cea5b8f43b96d260de160 -2026-07-08-self-referential-cordis-toolset.zh.md: ba0b6ae761ba1e560075177f5867ee634dcbfee8 +2026-07-08-self-referential-cordis-toolset.md: 80bffa3a2a959939f18fd1d3422607cf61895fc7 +2026-07-08-self-referential-cordis-toolset.zh.md: bdc8bfb7ed3b099fde2eb5d192800b2d6286201f diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index ba0b6ae761..bdc8bfb7ed 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -32,9 +32,9 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 沙箱全局变量刻意精简:一个带标签的直写 `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 的 JSON,并在记录日志前校验 `ToolExecuteReturn` 形状。挂载的插件接收的是一个白名单上下文门面,而非原始或透传的 `Context`;框架管道和以 context 为值的返回会被拒绝。服务读取需要声明 `inject`,保留 Cordis 的激活与卸载语义。`ctx.tools.get` 仅暴露 schema 视图,因此挂载代码无法绕过 `ToolRegistry.execute` 直接调用定义。 +挂载代码通过三道控制跨越 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 形式规范化为 `SchemaSpec`,包括对象包装器、`integer` 和可选字段。无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性契约,不叙述实现内部细节。 +边界将无歧义的 JSON-Schema 形式规范化为 `ParameterSchemaSpec`,同时保留 `integer`、原始对象开放性和 required 数组。直接使用 DSL 的对象节点必须声明 `additionalProperties`;无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性契约,不叙述实现内部细节。 ### 动态分组与挂载生命周期 @@ -62,7 +62,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 | 维度 | 结构化逐能力工具 | 单一 `cordis_mount` | |---|---|---| -| Schema 正确性 | `parameters` 仍然是模型编写的 JSON 对象,需要 SchemaSpec 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 | +| Schema 正确性 | `parameters` 仍然是模型编写的 JSON,需要统一 schema 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 | | 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JS;realm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 | | 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇(cordis 插件)覆盖当前和未来的所有效果 | | 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通的 cordis 语义 | diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 1d285ec9a1..87c9dfe066 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-core-data-structures-catalog.md: a2f5e0e7b06e34cb361e4944bbfa3692355a2cbe -2026-06-20-core-data-structures-catalog.zh.md: f886ee18dd64c4a7cf972b3b141e2c20eaa0c7b1 +2026-06-20-core-data-structures-catalog.md: d7e9d3d9b14fe723e3396c8167fe714b7613e2b5 +2026-06-20-core-data-structures-catalog.zh.md: a998c556020f99c34f2725a048401579e983b353 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index f886ee18dd..a998c55602 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -21,7 +21,7 @@ Status: implemented 确定其余案例的规则是:***你编写、持有或接收的类型是核心;为其提供类型推导、渲染或持久化的机制是子页面细节。*** 逐一验证如下: - 一个数据结构是**核心**的,如果它流经 agent loop 主干——无论加载了哪些插件,循环在每个轮次都会持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄)——**或者**它是插件作者面对某条流水线时编写的唯一标志性类型(`ToolDefinition`)。 -- `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标志性类型,撰写重要性压过了严格的"流经主干"规则。但它的类型推导机制——`SchemaSpec`/`InferArgs` DSL——是子页面细节(你编写的是 `ToolDefinition`;为其提供类型推导的机制你并不直接接触)。这就是主干与 seam 分界线的精确表述。 +- `ToolDefinition` 是核心(它是每个工具作者编写的东西),**即使循环从不持有它**——对于这一个标志性类型,撰写重要性压过了严格的"流经主干"规则。但它的类型推导机制——`ValueSchemaSpec`、`ParameterSchemaSpec`、`InferValue` 与 `InferArgs`——是子页面细节。这就是主干与 seam 分界线的精确表述。 - `ToolSchema` 是核心(它是流经每个步骤的模型请求 `GenerateOptions` 的一个字段),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 - 工具展示词汇(`ToolCallView`/`ToolResultView` 等)、`SessionPersistence` 持久性 seam 以及 bash 词汇是子页面。 diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml index 6b78fa480b..e118ff7330 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-11-property-based-testing.md: ac35591cb76c1d4243ecba153e8e143290716926 -2026-06-11-property-based-testing.zh.md: 4a0def2d28ef828fd68b78e4c1e100ce7f85d4f0 +2026-06-11-property-based-testing.md: a1bd4147a26a3d562899310e238096939fc2d01a +2026-06-11-property-based-testing.zh.md: 0e1934a24fcf22442420a664c9824bb74c0fe7f7 diff --git a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md index 4a0def2d28..0e1934a24f 100644 --- a/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md +++ b/.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md @@ -16,7 +16,7 @@ Status: implemented - **dsh-llm / BlockAssembler:** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start)。不变式:`blocks()` 计数 ≤ 已见到的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且仅产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无此类分片时默认为 `{kind:'stop'}`。 - **dsh-session:** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致;seq 严格单调递增;非消息事件不影响推导出的历史;推导出的内容与日志解耦。 -- **dsh-tools:** 任意 `SchemaSpec`。不变式:JSON Schema 的 `required` 等于每一层 `required:true` 的键集;转换是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`,而定向破坏(删除必填键、顶层非对象)被拒绝。这封堵了 validator 与 `InferArgs` 漂移的风险。 +- **dsh-tools:** 任意 `ParameterSchemaSpec`。不变式:JSON Schema 的 `required` 等于每一层 `required:true` 的键集;转换对合法声明而言是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`,而定向破坏(删除必填键、顶层非对象)被拒绝。聚焦用例覆盖每种根值类型、恰好一项匹配中的分支重叠与无匹配、显式开放性、原始默认值以及有损 JSON。这封堵了编译器、validator 与 `InferArgs` 之间的漂移风险。 - **dsh-agent-loop:** 任意发送调度,对接一个永不耗尽的适配器,通过 `agent/status` settle 信号驱动(无挂钟 sleep)。不变式:无消息丢失;轮次编号严格递增;状态转换保持在合法状态机上。 ## 后果 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index a7f8ad16a1..68402cc8a7 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-04-prune-unimplemented-subagent-vocabulary.md: f99a33163b48735f9634b5bb3dcac5c24eb893f8 -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 9e0b9125a4e98523b6782678a9f6356cd45dc2a7 +2026-07-04-prune-unimplemented-subagent-vocabulary.md: 890aca31f09f97ab6d9bf7c00f738d894695d9ad +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: f4525b609c1c1bab54a2063ceb1fa143ab75ec63 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index 9e0b9125a4..f4525b609c 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -11,7 +11,7 @@ Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`tool - **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):在作出决策时,每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅出现在脚本化测试 fixture(测试前置数据)中。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 - **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——包括 mock 也没有;spawn spec 断言的正是它们的*缺失*。 -`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 `SchemaSpec` 类型。三个后续 subagent 工作流(per-session 快照回放、fork seed 边界、ACP(Agent Client Protocol) 后端)都围绕这块接口面落地,却没有增长出哪怕一个消费方。 +在作出决策时,`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 schema 类型(现为 `ObjectJsonSchema`)。三个后续 subagent 工作流(per-session 快照回放、fork seed 边界、ACP(Agent Client Protocol) 后端)都围绕这块接口面落地,却没有增长出哪怕一个消费方。 ## 提案 From b63abe80d6f90eb0ccdcef7372a5909a05fa4598 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 19:54:31 +0800 Subject: [PATCH 038/113] docs(agent-note): add Chinese counterpart for unified-send Agent Note Bilingual pair required for docs dated on/after 2026-07-14; adds the .zh.md, the language-switcher lines, and the recorded .i18n.yaml sidecar. --- ...send-and-coalesced-user-messages.i18n.yaml | 6 +++ ...nified-send-and-coalesced-user-messages.md | 2 + ...ied-send-and-coalesced-user-messages.zh.md | 41 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml new file mode 100644 index 0000000000..00fe96d2f2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-22-unified-send-and-coalesced-user-messages.md: 61f9775c7b99c783a86e6a0814dc69d548ecbbe1 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d9ce4c482203592b8e5aada976debb6f088bb1eb diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 3bf721f0fd..61f9775c7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md) + ## Problem The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md new file mode 100644 index 0000000000..d9ce4c4822 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message + +Status: implemented + +[English](2026-07-22-unified-send-and-coalesced-user-messages.md) | 中文 + +## 问题 + +agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 + +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 + +## 决策 + +**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts, meta })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 + +**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:在当前日志位置追加的持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时的一次性 `injection` 轮次。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 + +**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 + +**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 + +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO;在 `InboxItemInfo` 上携带 `target`/`wakeup`)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 + +**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 + +## 考虑过的替代方案 + +- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 +- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了 `target`/`wakeup` 的事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 + +## 后果 + +投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 + +## 相关 + +- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 +- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 +- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 From 98d7bf8b1ae6a16a7fa9b1e6ff8e62627895c8e8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:37:01 +0800 Subject: [PATCH 039/113] docs(i18n): refresh persistence catalog for storage ownership --- docs/core-data-structures/persistence.i18n.yaml | 4 ++-- docs/core-data-structures/persistence.zh.md | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 98316924b3..23eb958ff9 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -persistence.md: d4bb4ed1b65eb74ff03483bf3a71a1736300834e -persistence.zh.md: 2e63ff9af2b561a70640fe90ded5faac0a900164 +persistence.md: 1e1ef35f39cc8262d7662f938b1e989f39fee5d6 +persistence.zh.md: 345c14b550debcc4f17d0d1236a7765ba15ddd62 diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 2e63ff9af2..345c14b550 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -106,5 +106,3 @@ interface CreateSessionOptions { - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 - -共享同一磁盘会话的多个后端通过[共享持久化写入协调器](../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)协调写入。 From 3fd72f7c7470379139b9b75ad36721e4558f3716 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 20:45:29 +0800 Subject: [PATCH 040/113] feat(agent): rename InboxItemInfo to AgentMessage with an id; send returns it Add a branded AgentMessageId assigned to each accepted send message and returned from send/followup/steer/inject (was void). Rename the inbox event payload InboxItemInfo to AgentMessage, carrying that id so a caller can correlate a queued item with its enqueue/dequeue/discard events. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 4 +- ...ied-send-and-coalesced-user-messages.zh.md | 4 +- docs/cordis-catalog/events.md | 54 ++++++++--------- docs/core-data-structures/core.md | 44 ++++++++++---- docs/event-producer-consumer.md | 36 +++++------ .../time-context/tests/time-context.spec.ts | 9 +-- .../tests/workspace-context.spec.ts | 9 +-- .../cordis/tool-cordis/src/api-catalog.ts | 12 ++-- packages/core/agent-loop/src/agent.ts | 23 ++++--- packages/core/agent-loop/src/inbox.ts | 11 ++-- packages/core/agent-loop/src/loop.ts | 16 +++-- packages/core/agent-loop/tests/inbox.spec.ts | 3 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 60 +++++++++++++------ packages/core/agent/tests/agent.spec.ts | 3 +- packages/core/agent/tests/invariant.spec.ts | 4 +- packages/core/scope/tests/invariant.spec.ts | 6 +- .../command-goal/tests/command-goal.spec.ts | 10 ++-- .../goal-session/tests/goal-session.spec.ts | 4 +- packages/goal/goal/tests/goal.spec.ts | 11 ++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 9 +-- packages/pty/pty-local/tests/index.spec.ts | 4 +- packages/pty/pty-local/tests/local.spec.ts | 4 +- packages/pty/pty/tests/service.spec.ts | 10 ++-- .../tool-pty/tests/loader-composition.spec.ts | 4 +- packages/pty/tool-pty/tests/tools.spec.ts | 4 +- packages/tasks/tasks/tests/tasks.spec.ts | 10 ++-- packages/ui/tui/tests/harness.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 22 +++---- scripts/gen-cordis-catalog.ts | 3 +- scripts/type-equiv.manifest.json | 7 ++- 32 files changed, 242 insertions(+), 170 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 00fe96d2f2..cbf529c8aa 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 61f9775c7b99c783a86e6a0814dc69d548ecbbe1 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d9ce4c482203592b8e5aada976debb6f088bb1eb +2026-07-22-unified-send-and-coalesced-user-messages.md: d88cd60f5c09f7961d7a59dbbd0703abdd26cc1a +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d16ed9f763a350e61abc00d3971e4a7711e8f610 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 61f9775c7b..d88cd60f5c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -20,7 +20,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj **Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. + +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. **cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index d9ce4c4822..d16ed9f763 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -20,7 +20,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO;在 `InboxItemInfo` 上携带 `target`/`wakeup`)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 + +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 **cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7c9ae4bea8..d61b126661 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:501`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -108,16 +108,16 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void +'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -129,16 +129,16 @@ Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/t * delivering them. Fires once per effective clearing call with every * discarded item, after `agent/cancel-requested` and before the abort. * @param agent - the agent whose inbox was cleared. - * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void +'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -152,16 +152,16 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. - * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void +'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:412`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:442`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +376,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:488`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c2d8b56a80..69f917eaad 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -409,16 +409,30 @@ The fixed-preset aliases own `target` and `wakeup`, so they accept only the rema type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'> ``` -The `agent/inbox/*` live events carry the resolved facts of one FIFO item; injection bypasses the FIFOs and never appears on them: +`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events: ```ts type-equiv /** - * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` - * live events. Source defaults are already applied, so these are the exact - * values the item was accepted with. `steering` is true for a `next-step` - * item drained between steps; a `next-turn` item is claimed at a turn boundary. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ -interface InboxItemInfo { +type AgentMessageId = Branded<'AgentMessageId'> +``` + +The `agent/inbox/*` live events carry one accepted message; injection bypasses the FIFOs and never appears on them: + +```ts type-equiv +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -488,8 +502,9 @@ abstract class Agent { * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -512,9 +527,10 @@ abstract class Agent { * ordinary message of its own turn. * @param content - the prompt content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-turn', wakeup: true }) + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) } /** @@ -526,9 +542,10 @@ abstract class Agent { * Idle steering falls back to a woken follow-up turn. * @param content - the steering content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: true }) + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) } /** @@ -541,9 +558,10 @@ abstract class Agent { * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. * @param content - the injected context content blocks. * @param options - source and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: false }) + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) } } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0825bbf348..2cf7e3a577 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:501`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:412`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:488`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 26bb5d7a9d..c87915dda7 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,7 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -42,14 +42,15 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session, status: 'running', ctx: new Context(), - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle: () => Promise.resolve(), diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c96cee8b38..e04c92fd19 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -174,15 +174,16 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { options: {}, session, status: 'idle', - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { content, source: options?.source ?? { kind: 'user' }, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle: () => Promise.resolve(), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f5872f5fcc..9e6087a4de 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -846,22 +846,22 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/inbox/dequeue', mode: 'emit', - signature: '\'agent/inbox/dequeue\'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void', - jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param info - the claimed item\'s accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/dequeue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void', + jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', }, { name: 'agent/inbox/discard', mode: 'emit', - signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void', - jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void', + jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', }, { name: 'agent/inbox/enqueue', mode: 'emit', - signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param info - the accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 670945a8fa..2ce1928af2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -6,15 +6,16 @@ * @module dsh-agent-loop/agent */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { agentEvents } from '@deepseek-ai/dsh-agent' +import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import { Agent } from '@deepseek-ai/dsh-agent' import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' -import { Inbox, inboxInfo, type InboxMessage } from './inbox.ts' +import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' /** Sessions already claimed by a concrete driver construction. */ @@ -196,9 +197,11 @@ export class ReactLoopAgent extends Agent { * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptMessage(content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage { + private acceptMessage( + id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, + ): InboxMessage { const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ content, source, contexts, wakeup }) + const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } @@ -219,23 +222,25 @@ export class ReactLoopAgent extends Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - send(content: ContentBlock[], options?: SendOptions): void { + send(content: ContentBlock[], options?: SendOptions): AgentMessageId { this.assertNotDisposed() + const id = AgentMessageId(randomUUID()) const target = options?.target ?? 'next-turn' const wakeup = options?.wakeup ?? true // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return } + if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return id } // next-step/wakeup is steering into the running turn; idle falls back to a // woken follow-up turn (there is no active turn to attach to). const steering = target === 'next-step' && this._status === 'running' const source = options?.source ?? { kind: 'user' } - const accepted = this.acceptMessage(content, source, wakeup, options) + const accepted = this.acceptMessage(id, content, source, wakeup, options) if (steering) { this.#inbox.steer(accepted) } else { this.#inbox.enqueue(accepted, wakeup) } - agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', inboxInfo(accepted, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering)) + return id } /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ @@ -346,7 +351,7 @@ export class ReactLoopAgent extends Agent { // Clear work already present before abort observers run. this.#inbox.clear() if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => inboxInfo(message, steering)) + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index e6bbbcfb7f..fd2810ea9a 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -7,10 +7,11 @@ */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { HookContext, InboxItemInfo } from '@deepseek-ai/dsh-agent' +import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' -/** One message waiting in an agent's inbox. */ +/** One message waiting in an agent's inbox; `id` is the value `send` returned. */ export interface InboxMessage { + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -22,10 +23,10 @@ export interface InboxMessage { * Build the `agent/inbox/*` event payload for one inbox item. * @param message - the accepted inbox record. * @param steering - whether the item is in the steering FIFO (`next-step`). - * @returns the live-event facts for enqueue/dequeue/discard. + * @returns the live-event message for enqueue/dequeue/discard. */ -export function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo { - return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } +export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { + return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } } /** diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 11733b17e8..6b57f87e74 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -5,11 +5,12 @@ * @module dsh-agent-loop/loop */ +import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' -import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' @@ -19,7 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' -import { inboxInfo, type Inbox, type InboxMessage } from './inbox.ts' +import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts' import type { TurnCancellation } from './cancellation.ts' /** Normalize thrown values while preserving an existing error code. */ @@ -279,7 +280,7 @@ async function runTurn( const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { - events.emit('agent/inbox/dequeue', inboxInfo(message, true)) + events.emit('agent/inbox/dequeue', agentMessage(message, true)) const prepared = preparePromptMessage(message.content, message.source, message.contexts) session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { @@ -297,7 +298,7 @@ async function runTurn( const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') - events.emit('agent/inbox/dequeue', inboxInfo(message, false)) + events.emit('agent/inbox/dequeue', agentMessage(message, false)) const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } @@ -542,9 +543,12 @@ async function runTurn( // enqueue event a public steer would, so the inbox ledger stays balanced // (every FIFO entry has a matching enqueue before its dequeue/discard). if (decision.action === 'continue' && decision.reason) { - const item: InboxMessage = { content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true } + const item: InboxMessage = { + id: AgentMessageId(randomUUID()), content: decision.reason.content, + source: decision.reason.source, contexts: [], wakeup: true, + } handle.inbox.steer(item) - events.emit('agent/inbox/enqueue', inboxInfo(item, true)) + events.emit('agent/inbox/enqueue', agentMessage(item, true)) } let shouldContinue = decision.action === 'continue' diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 91858e7050..791eae3bda 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' +import { AgentMessageId } from '@deepseek-ai/dsh-agent' import { Inbox } from '../src/inbox.ts' function message(text: string) { - return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } + return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } function resolverPair() { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 06a7e618f9..ebb756b139 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -56,7 +56,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f04c999cb6..e7d733c920 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -6,6 +6,7 @@ */ import type { Context } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -68,12 +69,31 @@ export interface SendOptions { export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'> /** - * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*` - * live events. Source defaults are already applied, so these are the exact - * values the item was accepted with. `steering` is true for a `next-step` - * item drained between steps; a `next-turn` item is claimed at a turn boundary. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ -export interface InboxItemInfo { +export type AgentMessageId = Branded<'AgentMessageId'> + +/** + * Brand a string as an {@link AgentMessageId}. + * @param id - the generated message id. + * @returns the same string, branded; no validation is performed. + */ +export function AgentMessageId(id: string): AgentMessageId { + return id as AgentMessageId +} + +/** + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. Source defaults are already + * applied, so these are the exact values the item was accepted with. `steering` + * is true for a `next-step` item drained between steps; a `next-turn` item is + * claimed at a turn boundary. + */ +export interface AgentMessage { + /** The id `send` returned for this message. */ + id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] @@ -194,8 +214,9 @@ export abstract class Agent { * input throws synchronously before any notification, enqueue, or append. * @param content - the model-facing content blocks to deliver. * @param options - target queue, wakeup decision, source, contexts, and meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): void + abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -218,9 +239,10 @@ export abstract class Agent { * ordinary message of its own turn. * @param content - the prompt content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-turn', wakeup: true }) + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-turn', wakeup: true }) } /** @@ -232,9 +254,10 @@ export abstract class Agent { * Idle steering falls back to a woken follow-up turn. * @param content - the steering content blocks. * @param options - source and attached contexts. + * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: true }) + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: true }) } /** @@ -247,9 +270,10 @@ export abstract class Agent { * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. * @param content - the injected context content blocks. * @param options - source and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): void { - this.send(content, { ...options, target: 'next-step', wakeup: false }) + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { ...options, target: 'next-step', wakeup: false }) } } @@ -292,31 +316,31 @@ declare module 'cordis' { * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. - * @param info - the accepted content, source, contexts, steering, and wakeup facts. + * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void + 'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void /** * The driver claimed one item out of the inbox: a queued item at a turn * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts. + * @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, info: InboxItemInfo): void + 'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void /** * `cancel()` (without `keepInbox`) dropped pending inbox items without * delivering them. Fires once per effective clearing call with every * discarded item, after `agent/cancel-requested` and before the abort. * @param agent - the agent whose inbox was cleared. - * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending. + * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItemInfo[]): void + 'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void /** * Effective broad cancellation was requested, before queued/steering work * is cleared or the active turn is aborted. This observe-only notification diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index d157eeab57..aefa739dde 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -4,6 +4,7 @@ import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Agent, + AgentMessageId, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' @@ -20,7 +21,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent { session: new Session(id), status: 'idle', ctx: new Context(), - send() {}, + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index b850e8743a..453752c402 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -58,7 +58,7 @@ describe('agent status invariants', () => { }) describe('agent inbox invariants', () => { - const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) + const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true }) it('accepts a dequeue and a discard covered by prior enqueues', async () => { const ctx = await setup() diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index bcb3944cbd..ca0841165b 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -42,8 +42,8 @@ describe('scoped-dispatch invariants', () => { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/inbox/enqueue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], - 'agent/inbox/dequeue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/enqueue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], + 'agent/inbox/dequeue': [agent, { id: AgentMessageId('m'), content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }], 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 6ea767e091..8964d02df2 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' @@ -48,10 +48,10 @@ function stubAgent(id: string): { agent: Agent; session: Session } { session, ctx: new Context(), get status() { return status }, - send() {}, - followup() {}, - steer() {}, - inject(content, options) { appendInjection(session, content, options) }, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index e323180e88..d4994466f9 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -488,7 +488,7 @@ describe('same-session goal driving', () => { if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { throw new Error('queue rejected') } - realSend(content, options) + return realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -510,7 +510,7 @@ describe('same-session goal driving', () => { test.ctx.goals.disarm(test.agent) throw new Error('queue rejected after disarm') } - realSend(content, options) + return realSend(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 18f8d5dfe2..4f6e4e2cfc 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -64,12 +64,13 @@ function stubAgentForSession(session: Session): StubAgent { session, ctx: new Context(), get status() { return status }, - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content, options) { if (shouldDefer) deferred.push({ content, options }) else appendInjection(session, content, options) + return AgentMessageId('stub') }, cancel() {}, whenIdle() { return Promise.resolve() }, @@ -476,7 +477,7 @@ describe('GoalService mutations', () => { let reject = true stub.agent.inject = (content, options) => { if (reject) throw new Error('injection rejected') - append(content, options) + return append(content, options) } ctx.agents.register(stub.agent) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 171fe1c0ef..a4f21392a3 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -31,9 +31,9 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { session, get status() { return status }, ctx: new Context(), - send() {}, - followup() {}, - steer() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), inject(content: ContentBlock[], options?: AliasSendOptions) { const source = options?.source ?? { kind: 'plugin', plugin: '' } session.append('user/message', { @@ -41,6 +41,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { source, ...options?.meta === undefined ? {} : { meta: options.meta }, }, { surfaceOp: 'append' }) + return AgentMessageId('stub') }, cancel() {}, whenIdle() { return Promise.resolve() }, diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 360b71c3f8..dffd80dfbd 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -3,7 +3,7 @@ import type { IPty, IPtyForkOptions } from 'node-pty' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index ad463a0352..512c84c6eb 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService from '@deepseek-ai/dsh-pty' import SandboxProvider from '@deepseek-ai/dsh-sandbox' @@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 4a690ed520..91842173c8 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty' import type { @@ -27,10 +27,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle', ctx: scopeFiber.ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index f71a637928..cf096ebb70 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 0439e5f876..b0da3adb27 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index b4d57191a2..cb72658968 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -23,10 +23,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, + send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c8755e4bb3..243f492d2c 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { + AgentMessageId, type Agent, type AgentCancelCause, type AgentOptions, @@ -152,16 +153,19 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e send(content, options) { sent.push(content) sentOptions.push(options) + return AgentMessageId('stub') }, followup(content, options) { sent.push(content) sentOptions.push(options) + return AgentMessageId('stub') }, steer(content, options) { steered.push(content) steeredOptions.push(options) + return AgentMessageId('stub') }, - inject() {}, + inject: () => AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b45b7a826f..23e297783c 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session' @@ -552,7 +552,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).not.toContain('queued') const queueSteering = (text: string): void => { - result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) } const drainSteering = (text: string): void => { result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -561,7 +561,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A steering queue for a different agent never touches this status line. const other = { ...result.agent, id: SessionId('other') } as unknown as Agent result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', other, { content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) + result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) await tick() expect(result.terminal.output).not.toContain('queued') @@ -574,7 +574,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // A non-steering queue (an idle-style send) leaves the badge untouched. result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }) drainSteering('first') await tick() expect(result.terminal.output).toContain('1 queued') @@ -628,7 +628,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const idle = await setup() // A steering queue arriving while idle has no status line to badge, so the // refresh is a no-op beyond requesting a render. - idle.ctx.emit('agent/inbox/enqueue', idle.agent, { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) + idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) await tick() expect(idle.terminal.output).not.toContain('Executing tools') @@ -2208,7 +2208,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2232,7 +2232,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2266,14 +2266,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2303,7 +2303,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2345,7 +2345,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 2eee7e0bb0..800f59c22e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,8 +35,9 @@ export const LINK_MAP: Record<string, string> = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', + AgentMessage: 'core.md', + AgentMessageId: 'core.md', HookContext: 'core.md', - InboxItemInfo: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmFailure: 'llm-streaming.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5014148504..45c08e494a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -83,7 +83,12 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "InboxItemInfo", + "symbol": "AgentMessageId", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AgentMessage", "source": "packages/core/agent/src/types.ts" }, { From 866b5437009b5e899c53db9ce3a73b6348713a1b Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 20:50:13 +0800 Subject: [PATCH 041/113] docs(agent): fix stale 'info' param name in agent/inbox/enqueue JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @param was renamed info→message; the body sentence still named info. Found by fresh-eye review. Regenerated the catalog/graph docs. --- docs/cordis-catalog/events.md | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent/src/types.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d61b126661..7f1015a691 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -142,12 +142,12 @@ Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/t ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `info` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** * A detached, frozen item entered the agent's inbox (queued or steering - * FIFO). Source defaults are already applied, so `info` holds the exact + * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9e6087a4de..293a514c74 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -861,7 +861,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e7d733c920..9986affe6b 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -311,7 +311,7 @@ declare module 'cordis' { 'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void /** * A detached, frozen item entered the agent's inbox (queued or steering - * FIFO). Source defaults are already applied, so `info` holds the exact + * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. From cff3cb6dcd18edd96f55203457d28cdb5d710970 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:52:40 +0800 Subject: [PATCH 042/113] docs(i18n): refresh persistence Agent Notes --- ...-06-18-shared-persistence-write-coordinator.i18n.yaml | 4 ++-- ...2026-06-18-shared-persistence-write-coordinator.zh.md | 9 ++++----- .../2026-06-20-prune-dead-seam-methods.i18n.yaml | 4 ++-- .../2026-06-20-prune-dead-seam-methods.zh.md | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 8d572c486f..3e805481ad 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-18-shared-persistence-write-coordinator.md: 12800b118bd2fd1189319d0edf37cace3d916a9c -2026-06-18-shared-persistence-write-coordinator.zh.md: 23d6c66989880369c4e26c38145559c198ea1033 +2026-06-18-shared-persistence-write-coordinator.md: 2349c50735045ae99e70bb5594b69f4252994b91 +2026-06-18-shared-persistence-write-coordinator.zh.md: c68cbd984197bf68415d1f2bb68f297f51548bc3 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 23d6c66989..c68cbd9841 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -18,11 +18,10 @@ Status: implemented ### 钩子接口(`PersistenceBackend<TornMarker>`) -六个方法(五个必需 + 一个可选的生命周期钩子)——协调器与存储之间唯一的 seam: +五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 -- `loadStored(id)`——按 id 读取已存储的前缀,扫描任何存储范围(JSONL 的每个 cwd bucket;SQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 进行创建碰撞探测。 -- `loadLive(id, cwd)`——读取限定于 `cwd` 的已存储前缀。**与 `loadStored` 有意区分**:HMR live-adoption 只能接管与存活会话处于同一 cwd 的持久化日志;同 id 但不同 cwd 的日志是碰撞而非恢复。合并二者会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`。 +- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有 cwd bucket;SQLite 的 id 全局唯一)。恢复/加载、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 - `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 @@ -39,8 +38,8 @@ Status: implemented ## 曾考虑的替代方案 - **后端继承的基类**——否决,改用组合:后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍可完全不使用协调器、直接实现抽象服务。 -- **更宽的钩子面**——每个候选钩子都被折叠掉:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与首批事件原子提交);没有单独的创建碰撞探测(即 `loadStored(id) !== undefined`);`list()` 也不经由协调器透传(列举不需要任何编排)。 +- **更宽的钩子面**——每个候选钩子都被折叠掉:没有限定存储范围的实时查找,因为 `loadStored` 加上协调器的 cwd 检查即可维持碰撞边界;没有存储定位器泛型,因为经验证的 JSONL 元数据可还原其路径,而 SQLite 已按 id 绑定;没有单独的 `materialize` 钩子,因为首批事件必须与物化原子提交;没有单独的创建碰撞探测,因为它就是 `loadStored(id) !== undefined`;`list()` 也不经由协调器透传,因为列举不需要任何编排。 ## 后果 -协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、保留未提交的缓冲区,并以后端 teardown 为静止状态边界。其钩子面保持窄小:碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。 +协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、保留未提交的缓冲区,并以后端 teardown 为静止状态边界。其钩子面保持窄小:标识校验、接管与碰撞检查复用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml index 91b82dcd75..bcca5a0490 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-20-prune-dead-seam-methods.md: bb91194ed0483ca4c43acdde8370e7152ac876ea -2026-06-20-prune-dead-seam-methods.zh.md: f441ac0d91b4f673cbbbd4148c9183cc1185a54f +2026-06-20-prune-dead-seam-methods.md: 70596d908bb1d7559e876d93bce0e25874ce1ff0 +2026-06-20-prune-dead-seam-methods.zh.md: 0953eaa57dc9b490a7399a412338fc21a06a0ad2 diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md index f441ac0d91..0953eaa57d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md @@ -14,7 +14,7 @@ Status: implemented 该抽象服务在 create/append 之外声明了更多操作:`load`、`list`、`has`、`delete`。`ctx.sessionPersistence` 的生产消费方只用了两个:agent loop(智能体循环)的恢复路径调用 `load()`([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)),ACP(Agent Client Protocol)桥接层为 `session/list` 调用 `list()`([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts))。在 `packages/*/src` 和 `examples/` 中 grep 所有 `sessionPersistence.*` / `persistence.*` 的使用,找不到对该服务的 `has(` 或 `delete(` 调用。`packages/ui/acp/src/index.ts` 中的 `.has(`/`.delete(` 调用作用于内存中的 `SessionStore` 和一个本地的 loading id `Set`,而非 persistence。`has`/`delete` 的唯一调用者是契约测试套件和各后端的 spec。 -`has()` 不仅没有被使用——它还是共享协调器中最复杂的分支:带有多行理由说明的“已跟踪/未跟踪”双重探测(对实时跟踪的 session 使用 `loadLive(id, cwd)`,对未跟踪 session 使用 `loadStored(id)`)。`delete()` 则拖入每个后端都必须实现的 `deleteStored` 后端 hook。这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:契约测试覆盖了两者,但已发布代码从不会询问“这个 session 是否已持久化?”或删除某个 session。 +`has()` 不仅未被使用:在 `loadStored(id)` 已负责持久化存在性检查的情况下,它仍增加了协调器的已跟踪/未跟踪探测和一个契约分支。`delete()` 则拖入每个后端都必须实现的 `deleteStored` 后端 hook。这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:契约测试覆盖了两者,但已发布代码从不会询问“这个 session 是否已持久化?”或删除某个 session。 ## 决策 From c402cc24225d666ade96b8731e7ef56a5cb46dd9 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 20:58:35 +0800 Subject: [PATCH 043/113] test(snapshot): re-record acp fixtures after merging master Merge took master's snapshot fixtures; re-record the two keyless scenarios so they reflect this branch's user/message coalescing and the abstract Agent + AgentMessageId type dump. --- .../tests/snapshots/code-mode-workspace-context/session.jsonl | 2 +- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- .../tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 88152d69cd..3f664e8e09 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -87,7 +87,7 @@ {"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content.lines.map(line => line.text).join(String.fromCharCode(10));\"}"}} {"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"<path>./nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}} {"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"Touch this file to discover the nested workspace instruction."}],"isError":false},"sourceEventSeqs":[85],"surfaceOp":"append"} -{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"user/message","seq":88,"time":1784811336862,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n</system-reminder>"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}} {"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 7238728b48..7153e81bbc 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit<SendOptions, 'contexts'> {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 56fc0733c4..b602c679e0 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit<SendOptions, 'contexts'> {\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} From 8313827c37ea54cb65c3a2de6f6b006daac18ff7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:13:34 +0800 Subject: [PATCH 044/113] docs(i18n): allow zero-step Goal Rounds --- docs/glossary.i18n.yaml | 2 +- docs/glossary.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 03225c38dd..e49ddd96c6 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write glossary.md: 414fb5342886ce9fc775c529a7e61f0d760e93c6 -glossary.zh.md: 0a307b08dad97613e64b43a8857e9f24339f39da +glossary.zh.md: ed3009a054815f1c7165fc322e44cc9521527643 diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index 0a307b08da..ed3009a054 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -21,7 +21,7 @@ FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 S ## 目标 - **目标**:附着在现有会话上的单个持久完成目标,带有按修订号演进的 `active` / `paused` / `blocked` / `complete` 阶段和 Goal Round 上限;`blocked` 保留策略代码与说明。目标是一种状态,不是调度器,也不是一段独立对话;会话日志仍是其真源。 -- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的[轮次](#turn),其中包含一个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限。<a id="goal-round"></a> +- **Goal Round**:为当前目标接纳的一次续行周期。同会话驱动器将 Goal Round 具体化为一个来源为目标的[轮次](#turn),其中可包含零个或多个步骤;同一会话中无关的人类轮次不消耗 Goal Round 上限。<a id="goal-round"></a> - **目标激活**:续行消费方接纳下一个 Goal Round 的进程本地权限。激活态为 `armed` 或 `disarmed`;它有意不参与持久回放,因此恢复和 fork 后,必须由人类随后通过 `/goal` 或模型工具授权恢复变更,自动工作才可开始。 ## 人类命令 From 91f5e4d9c8530762a89eb070cb7da0f682bd71eb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:14:34 +0800 Subject: [PATCH 045/113] docs(i18n): clarify cancellation and orphan results --- docs/core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- docs/core-data-structures/core.i18n.yaml | 2 +- docs/core-data-structures/core.zh.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 9674987307..4140fb9cc5 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: ccac8590ceb2683bd5f55681288bc433d84f2657 +compaction.zh.md: 0949b1cb62e4010cf0172b72aa3790b3f4a2acd6 diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index ccac8590ce..0949b1cb62 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -64,7 +64,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' 压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering(中途引导)已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 -该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与孤立结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 +该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 ## 工具结果剪枝产出 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index a01c020939..9f9edd7e94 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write core.md: 9446152b909cd3e0105fe44321b16353228c0730 -core.zh.md: 55b04e3ef4215fc984824015761f5f41537656a0 +core.zh.md: 1cc9bbb71cc70ba99d5920ded0b9e275f3aed8a6 diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 55b04e3ef4..1cc9bbb71c 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -456,7 +456,7 @@ interface Agent { `AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 -cause 是由 TypeScript 强制约束的同进程输入。活跃持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`;该值在发布 `turn/end` 前退役。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 [事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 From 8f4544c488fcacc9cfec92ffb81cea7c74233b41 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:38:26 +0800 Subject: [PATCH 046/113] docs(i18n): address Agent Note review --- ...07-12-agent-scope-runtime-design.i18n.yaml | 2 +- ...026-07-12-agent-scope-runtime-design.zh.md | 50 +++++++++---------- ...26-06-30-subagent-observe-enrich.i18n.yaml | 2 +- .../2026-06-30-subagent-observe-enrich.zh.md | 2 +- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 451843a4c0..e226be3a7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-agent-scope-runtime-design.md: ff7fba1e6f8d496080acbceddb06691c8cddc5f5 -2026-07-12-agent-scope-runtime-design.zh.md: e2aa894b638fb3183750bee96d8a678f25f53de1 +2026-07-12-agent-scope-runtime-design.zh.md: 1225c10b4780c9c0c58ab7d8ff2dafa1379d01be diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index e2aa894b63..1225c10b47 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -14,19 +14,19 @@ Status: implemented ## 决策 -运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体与共享 layer store;每个活跃的注册表对象有一条入口记录;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和静默态。 +运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体与共享 layer store;每个活跃的注册表对象有一条注册表条目;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和完全停稳态。 该设计可概括为七项选择: | 问题 | 权威机制 | |---|---| | 选择全局加某个 agent 的注册 | 不透明作用域键、路由载体与共享 layer store | -| 拥有一个活跃的 agent 或会话 | 由其 disposer 捕获的单条注册表入口 | +| 拥有一个活跃的 agent 或会话 | 由其 disposer 捕获的单条注册表条目 | | 协调创建/恢复 | 单个 `AgentCreationTransaction` | | 保护持久化、队列、模型或协议格式数据 | 在该边界处一次性物化 | | 在同一进程内传递类型化值 | Readonly 借用契约 | | 组合模型可见的 prompt 与工具表面 | 单个共享工具视图加权威的 assembly-waterfall 结果 | -| 协调 subagent、worker 和进程关闭 | 单个取消信号加该边界独立的终止态/静默态事实 | +| 协调 subagent、worker 和进程关闭 | 单个取消信号加该边界独立的终止态/完全停稳态事实 | 本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与 prompt、subagent 与工作流,最后是可执行检查。 @@ -60,7 +60,7 @@ Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()` ## 作用域路由:一个不透明键选择一层 -scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个组合的服务过滤器和作用域谓词,而包私有地记录不透明键,并单独暴露作用域 fiber 的静默 disposer。 +scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个组合的服务过滤器和作用域谓词,而包私有地记录不透明键,并单独暴露会等待作用域 fiber 完全停稳的 disposer。 ### 作用域标识使用对象标识 @@ -86,13 +86,13 @@ Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 创建和恢复是一个具有多个阶段的异步生命周期,而非多个生命周期。`AgentCreationTransaction` 拥有调用方和工厂的活跃性、可选取消、私有资源、发布、回滚,以及每个所有者观察到的记忆化拆除。 -### 注册表入口是唯一的活跃标识记录 +### 注册表条目是唯一的活跃标识记录 -AgentRegistry 和 SessionStore 各为每个活跃对象保留一条入口。入口持有稳定 ID、对象、作用域载体,以及属于该对象的少量发布或追加状态。 +AgentRegistry 和 SessionStore 各为每个活跃对象保留一条注册表条目。注册表条目持有稳定 ID、对象、作用域载体,以及属于该对象的少量发布或追加状态。 -detach 闭包捕获其确切入口。它仅在映射仍指向该入口时才删除,因此旧的 disposer 无法删除一个复用相同 ID 的后续对象。注册表不会重读可变的调用方对象来决定标识。 +detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册表条目时才删除,因此旧的 disposer 无法删除一个复用相同 ID 的后续对象。注册表不会重读可变的调用方对象来决定标识。 -没有预留 API。调用方提供的 ID 在最终入口时被接纳。并发的同 ID 操作可能都完成私有 setup;恰好一个最终 `enter()` 成功,每个失败者回滚其私有资源。前一个 disposer 达到静默后,顺序复用即为有效。 +没有预留 API。调用方提供的 ID 在最终写入注册表时被接纳。并发的同 ID 操作可能都完成私有 setup;恰好一个最终 `enter()` 成功,每个失败者回滚其私有资源。前一个 disposer 达到完全停稳态后,顺序复用即为有效。 ### 事务在等待之前就拥有准备工作 @@ -112,8 +112,8 @@ Setup 接收完整的子 context,可以等待插件激活。它可以注册工 发布按观察者所需的顺序接纳和宣告资源: -1. 入口 session。 -2. 入口 agent。 +1. 将 session 写入注册表。 +2. 将 agent 写入注册表。 3. 宣告 `session/created`。 4. 宣告 `agent/created`。 5. 启用公开驱动。 @@ -122,7 +122,7 @@ Setup 接收完整的子 context,可以等待插件激活。它可以注册工 Agent 在两个注册表和创建通知都达成一致之前绝不驱动。同步监听器可以否决或 dispose 一个所有者;事务记录发布进行中,并等待该回调栈展开后再继续拆除。每个已开始的创建宣告在回滚期间都有匹配的销毁宣告。 -以下序列图隔离了非显而易见的竞态:同步创建监听器可以在发布调用栈仍拥有两个注册表入口时请求 dispose。拆除必须立即停用,但要等待该栈展开后才停止和分离任何东西。 +以下序列图隔离了非显而易见的竞态:同步创建监听器可以在发布调用栈仍拥有两个注册表条目时请求 dispose。拆除必须立即停用,但要等待该栈展开后才停止和分离任何东西。 ```mermaid sequenceDiagram @@ -160,7 +160,7 @@ sequenceDiagram ## 会话追加:物化、验证、提交、通知 -会话事件跨越持久化边界,因此追加操作拥有其数据。算法的其余部分使用一条附加的入口和一个提交点。 +会话事件跨越持久化边界,因此追加操作拥有其数据。算法的其余部分使用一条已附加的注册表条目和一个提交点。 ### 持久化数据一次性物化 @@ -173,7 +173,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 追加遵循一个序列: 1. 物化持久化事件和表面意图。 -2. 声明 SessionEntry 并拒绝该入口上的重入追加。 +2. 取得 SessionEntry 的独占所有权,并拒绝该注册表条目上的重入追加。 3. 解析作用域回调并运行内部不变式验证。 4. 恰好推送一次;这是提交点。 5. 逐个通知每个观察者,隔离同步和异步失败。 @@ -268,7 +268,7 @@ Subagent 启动有一次所有权转移。提供方拥有部分资源直到其 s `SubagentProvider.start()` 和 `SubagentService.start()` 返回 `Promise<SubagentRun>`。Promise 仅在后端建立了它所承诺的子级之后才兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` 就绪 promise。 -`SubagentStartRequest.signal` 是必需的。中止它会在启动期间和就绪之后请求取消。`SubagentRun.dispose()` 也请求取消并等待静默。没有单独的公开 `run.cancel()` 通道。 +`SubagentStartRequest.signal` 是必需的。中止它会在启动期间和就绪之后请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。 可选的 `sendMessage()` 支持能接受 steering 的活跃后端。可选的 `resume()` 返回 `Promise<SubagentRun>`,因为恢复的子级有相同的异步就绪边界。 @@ -284,7 +284,7 @@ Spawn 使用空会话种子。Fork 使用经验证的已完成轮次前缀。对 ### ACP 提供方拥有进程直到就绪或清理 -ACP 提供方跨越真实的进程和协议格式边界,因此它保留验证、环境清洗、消息序列化、abort/进程竞争和 kill-to-exit 静默。 +ACP 提供方跨越真实的进程和协议格式边界,因此它保留验证、环境清洗、消息序列化、abort/进程竞争,以及从 kill 到进程退出并完全停稳的过程。 Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn 失败、RPC 失败或无效启动响应在拒绝前回收进程。就绪后,result 映射 ACP prompt 结果和流式输出;dispose 请求取消、关闭连接并通过一条记忆化路径等待进程退出。 @@ -296,15 +296,15 @@ Worker 和编辑器桥接比同进程注册表需要更多状态,因为消息 工作流宿主保持待定的提供方 start promise 和已发布的子级记录。子级仅在异步 `SubagentService.start()` 兑现时才从待定变为已发布;被拒绝的 start 清理其部分提供方工作且不产生子级生命周期对。 -一个宿主拥有的 AbortController 向待定和活跃子级提供必需的 signal。关闭工作流准入中止该 signal,因此没有重复的 `ChildCancel` worker RPC 或显式的宿主侧 `run.cancel()` 扇出。静默等待待定 start 和已发布子级 dispose 两者。 +一个宿主拥有的 AbortController 向待定和活跃子级提供必需的 signal。关闭工作流准入中止该 signal,因此没有重复的 `ChildCancel` worker RPC 或显式的宿主侧 `run.cancel()` 扇出。完全停稳需要等待待定 start 和已发布子级 dispose 两者。 -Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲裁、精确的子级计数、worker 死亡处理、优雅终止、迟到/重复消息拒绝和有界清理,因为结果接收、worker 退出和子级静默是真正独立的事实。 +Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲裁、精确的子级计数、worker 死亡处理、优雅终止、迟到/重复消息拒绝和有界清理,因为结果接收、worker 退出和子级完全停稳是真正独立的事实。 ### 终端结果与物理清理保持分离 工作流结果按公开优先级规则记录首个被接受的终端结果。该结果选定后清理可以继续:活跃子级仍需 dispose,worker 仍需终止,慢速外部后端可能超出配置的优雅期限。 -公开 dispose 在调用回调之前声明其记忆化 promise。Worker 死亡在处理任何排队的迟到子级请求之前关闭准入,合成缺失的生命周期结束,并启动子级/进程清理而不重写已声明的结果。 +公开 dispose 在调用回调之前取得其记忆化 promise 的所有权。Worker 死亡在处理任何排队的迟到子级请求之前关闭准入,合成缺失的生命周期结束,并启动子级/进程清理而不重写已声明的结果。 ### ACP prompt 结算不依赖渲染成功 @@ -332,7 +332,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md)拥有 Program 构造、语义事件发现和解析器生成规则。 -行为测试固定了作用域路由和 dispose、最终入口碰撞清理、发布回滚、有序静默、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 +行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 @@ -344,7 +344,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 ### 在 setup 前预留 agent 和 session ID -预留防止重复的私有 setup 工作,但需要跨服务能力、释放排序、废弃预留清理和已准备对象绑定。ID 由调用方提供,并发复用是调用方错误;最终入口可以选择赢家,而失败的事务干净地回滚。 +预留防止重复的私有 setup 工作,但需要跨服务能力、释放排序、废弃预留清理和已准备对象绑定。ID 由调用方提供,并发复用是调用方错误;最终写入注册表时可以选择赢家,而失败的事务干净地回滚。 ### 对每个类型化的同进程参数做快照 @@ -352,7 +352,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 ### 为就绪、取消和 dispose 提供独立控制器 -并行哨兵可能都镜像一个操作是否活跃。一个事务或 start promise 拥有操作;独立 promise 仅在发布展开、外部工作、终端结果和物理静默可以独立结算时才保留。 +并行哨兵可能都镜像一个操作是否活跃。一个事务或 start promise 拥有操作;独立 promise 仅在发布展开、外部工作、终端结果和物理层面的完全停稳可以独立结算时才保留。 ### 保留同步 subagent start 加 `run.started` @@ -364,21 +364,21 @@ Waterfall 之后的恢复步骤会在文档化的协作式 seam 之后创建第 ### 用同进程加固替代 worker/进程生命周期守卫 -Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化边界。首个结果仲裁、验证、环境清洗和静默进程清理即使在敌对的同进程回调机制不存在时仍然必要。 +Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化边界。首个结果仲裁、验证、环境清洗和使进程完全停稳的清理即使在敌对的同进程回调机制不存在时仍然必要。 ## 后果 -实现更小,其证明与所有权图具有相同的形状。一个键选择一层,一条入口拥有一个活跃注册表对象,一个事务拥有创建,一个解析器拥有工具视图,一个异步 promise 转移 subagent 所有权。 +实现更小,其证明与所有权图具有相同的形状。一个键选择一层,一条注册表条目拥有一个活跃注册表对象,一个事务拥有创建,一个解析器拥有工具视图,一个异步 promise 转移 subagent 所有权。 ### 设计保证的内容 - 作用域贡献仅在其精确的 agent 视图中可见,并随该作用域一起 dispose。 -- 创建和恢复不暴露部分配置的句柄;最终入口的失败者和发布失败清理每个已准备的资源。 +- 创建和恢复不暴露部分配置的句柄;最终写入注册表时的失败者和发布失败清理每个已准备的资源。 - Dispose 在 driver 排空和最终会话工作期间保留作用域监听器和持久化,然后撤销作用域。 - 持久化、队列、模型、worker、进程和协议格式的值在其真实边界处被拥有;类型化的同进程值遵循 readonly 契约。 - ToolRegistry 的展示、查找和执行在专家 assembly 变换之前解析相同的活跃视图,已提交的结果有一个不可变的观察点。 - 注册表贡献是确定性输入,而可信的 assembly waterfall 拥有最终的模型可见组合。 -- Subagent start 仅返回就绪的 run,必需的 signal 取消待定或活跃的工作,dispose 到达后端的静默契约。 +- Subagent start 仅返回就绪的 run,必需的 signal 取消待定或活跃的工作,dispose 到达后端的完全停稳契约。 - Worker/进程结果优先级和清理在死亡、迟到消息和有界拆除下保持正确。 ### 代价与局限 diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml index c775a6113b..3024b92250 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml @@ -3,4 +3,4 @@ # 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: ce8374b4f046e97c1366256c5c6626013aa8c73f +2026-06-30-subagent-observe-enrich.zh.md: f433c3cbd451e982cfe96457794895120f9bc200 diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md index ce8374b4f0..f433c3cbd4 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 曾考虑的替代方案 -**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物),放在请求与两个生命周期载荷上。早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(此处没有任何逻辑解释它,唯一消费方是 CC 方言桥接层)。CC 桥接层改为直接为其 SubagentStart/Stop 的 `agent_type` matcher 填入 Claude Code 自身的默认值 `"general-purpose"`,因此本 Agent Note 只交付一项丰富化:`lastAssistantMessage`。 +**`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`**:推迟;见下文。 From 98ee4ce429ab473725ee250771a6991db012b853 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 21:41:42 +0800 Subject: [PATCH 047/113] =?UTF-8?q?fix(agent-loop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20quiet-item=20parking,=20meta,=20discard=20balance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve six review findings on the unified-send change: - quiet (wakeup:false) queued items no longer un-park the driver; the inbox distinguishes hasWakingQueued (drives the loop, idle/quiescence) from hasQueued (anything to dequeue), so a lone quiet item parks at idle and rides the next waking send. whenIdle/cancel settle off the waking signal, so cancelling a parked quiet item no longer hangs whenIdle. - SendOptions.meta on queued/steering sends now reaches the durable user/message and steering/message (was dropped except on injection). - a terminal agent/turn-stop that drops pending steering emits agent/inbox/discard so the enqueue-dequeue-or-discard ledger balances. - the loop-authored continuation reason is snapshotted and frozen like a public send. - gen-cordis-api collects exported classes (body-stripped) so the now- abstract-class Agent and its transitive shapes reappear in the API catalog. Adds regression tests for each and re-records the affected snapshot. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 2 + ...ied-send-and-coalesced-user-messages.zh.md | 2 + .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 48 +++++++++++++++++++ packages/core/agent-loop/src/agent.ts | 23 ++++++++- packages/core/agent-loop/src/inbox.ts | 18 ++++++- packages/core/agent-loop/src/loop.ts | 46 +++++++++++++----- packages/core/agent-loop/tests/cancel.spec.ts | 32 +++++++++++++ .../agent-loop/tests/inbox-invariant.spec.ts | 31 ++++++++++++ packages/core/agent-loop/tests/loop.spec.ts | 22 +++++++++ scripts/gen-cordis-api.ts | 37 ++++++++++++-- 13 files changed, 245 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index cbf529c8aa..367a7e9ef5 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: d88cd60f5c09f7961d7a59dbbd0703abdd26cc1a -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d16ed9f763a350e61abc00d3971e4a7711e8f610 +2026-07-22-unified-send-and-coalesced-user-messages.md: e969012c8172bbba78f04943be46b7dc866ccb7f +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 35cc183a7c7bfd8a649557e50e6e7d379f8f8193 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index d88cd60f5c..e969012c81 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -36,6 +36,8 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection. Every FIFO exit publishes exactly one lifecycle event: a terminal `agent/turn-stop` that drops pending steering now emits `agent/inbox/discard` for it, and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. + ## Related - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index d16ed9f763..35cc183a7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -36,6 +36,8 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性 `agent/turn-stop` 现在会为它发出 `agent/inbox/discard`,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 + ## 相关 - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 7153e81bbc..d264fe9a67 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index b602c679e0..db7029cf50 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4d820084b5..14a2a9ca52 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1151,6 +1151,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'Agent', + declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n}', + }, + { + name: 'AgentCancelCause', + declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};', + }, { name: 'AgentFactory', declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>;\n}', @@ -1159,10 +1167,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentHandle', declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise<void>;\n}', }, + { + name: 'AgentMessageId', + declaration: 'export type AgentMessageId = Branded<\'AgentMessageId\'>;', + }, { name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}', }, + { + name: 'AgentStatus', + declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', + }, + { + name: 'AliasSendOptions', + declaration: 'export type AliasSendOptions = Omit<SendOptions, \'target\' | \'wakeup\'>;', + }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -1255,6 +1275,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CallId', declaration: 'export type CallId = Branded<\'CallId\'>;', }, + { + name: 'CancelOptions', + declaration: 'export interface CancelOptions {\n keepInbox?: boolean;\n}', + }, { name: 'CodeBindingErrorClass', declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}', @@ -1495,6 +1519,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'LlmAdapter', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}', + }, { name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', @@ -1695,6 +1723,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScopeKey', declaration: 'export type ScopeKey = object;', }, + { + name: 'SendOptions', + declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', + }, + { + name: 'SendTarget', + declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';', + }, + { + name: 'Session', + declaration: 'export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + }, { name: 'SessionEvent', declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -1767,6 +1807,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionSurface', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -1899,6 +1943,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, + { + name: 'SurfaceIntent', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', + }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 2ce1928af2..7c2dfd0f60 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -201,7 +201,10 @@ export class ReactLoopAgent extends Agent { id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, ): InboxMessage { const contexts = options?.contexts ?? [] - const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup }) + const accepted = snapshotJsonValue({ + id, content, source, contexts, wakeup, + ...options?.meta !== undefined ? { meta: options.meta } : {}, + }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') } @@ -344,6 +347,11 @@ export class ReactLoopAgent extends Agent { agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } if (!keepInbox) { + // Whether the parked driver was already scheduled to run: a waking item + // woke `waitForQueued`, so the loop WILL resume and settle idle waiters + // itself through the pre-run-cancel path (possibly after a replacement + // prompt). Only a lone quiet item leaves the loop truly parked. + const willResume = this.#inbox.hasWakingQueued // Snapshot before clearing so the discard notification carries the exact // dropped items; a replacement synchronously enqueued by an // `agent/cancel-requested` observer belongs to the next turn, not here. @@ -354,6 +362,15 @@ export class ReactLoopAgent extends Agent { const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } + // Clearing a parked quiet (`wakeup:false`) item reaches quiescence with no + // status transition and without waking the parked driver, so settle any + // `whenIdle` waiter here. When a waking item was present the loop resumes + // and settles itself; while `running` (including the post-turn flush + // window) the driver still owns the eventual idle transition. So settle + // only for a parked, non-running agent whose sole cleared work was quiet. + if (cancellation === undefined && !willResume && this._status !== 'running') { + this.settleIdleWaiters() + } } cancellation?.request(resolvedCause) } @@ -365,7 +382,9 @@ export class ReactLoopAgent extends Agent { */ whenIdle(): Promise<void> { if (this._status === 'disposed') return this.done - if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve() + // A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the + // driver stays parked — so gate on hasWakingQueued, not hasQueued. + if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve() // Agent-owned waiters survive concurrent fiber disposal. return new Promise<void>((resolve) => { this.idleWaiters.push(() => { diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index fd2810ea9a..3cb82944e4 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -7,6 +7,7 @@ */ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from '@deepseek-ai/dsh-session' import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' /** One message waiting in an agent's inbox; `id` is the value `send` returned. */ @@ -17,6 +18,8 @@ export interface InboxMessage { contexts: HookContext[] /** Whether the item is marked to wake the driver or force a continuation. */ wakeup: boolean + /** Opaque durable JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue } /** @@ -39,11 +42,22 @@ export class Inbox { private steeringMessages: InboxMessage[] = [] private wakeup: (() => void) | undefined - /** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */ + /** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */ get hasQueued(): boolean { return this.queuedMessages.length > 0 } + /** + * True while a queued message wants to wake the driver — the "should the loop + * run" signal read by the idle wait's fast path, the loop's idle-publish + * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this + * false, so the driver stays parked until a waking send (or a waking item + * ahead of it in FIFO order) drives the loop; the quiet item then rides along. + */ + get hasWakingQueued(): boolean { + return this.queuedMessages.some(message => message.wakeup) + } + /** True while steering messages are pending — read by cancellation and the loop's stop-override check. */ get hasSteering(): boolean { return this.steeringMessages.length > 0 @@ -116,7 +130,7 @@ export class Inbox { * loop can exit). */ waitForQueued(cancel: Promise<void>): Promise<void> { - if (this.hasQueued) return Promise.resolve() + if (this.hasWakingQueued) return Promise.resolve() const { promise, resolve } = Promise.withResolvers<void>() this.wakeup = resolve void cancel.then(resolve) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 6b57f87e74..86d7c106f2 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -202,9 +202,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> { while (!handle.isDisposed()) { // An idle listener can enqueue and cancel replacement work before the next // wait is installed. Consume that empty marker before parking the driver. + // A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on + // hasWakingQueued, not hasQueued. if (handle.isPreRunCancelled()) { handle.clearPreRunCancel() - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { handle.settleIdle() handle.setStatus('idle') continue @@ -218,7 +220,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> { // a replacement prompt still runs before the eventual idle transition. if (handle.isPreRunCancelled()) { handle.clearPreRunCancel() - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { // Settle before publishing idle: the already-idle path has no status // transition, while an idle listener can register waiters for new work. handle.settleIdle() @@ -235,10 +237,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> { } // A synchronous `running` listener can cancel before `runTurn`; balance the - // status only when no replacement prompt was queued by that listener. + // status only when no waking replacement prompt was queued by that listener + // (a lone quiet item parks at idle rather than driving a turn). if (cancellation.signal.aborted) { handle.clearTurnCancellation(cancellation) - if (!handle.inbox.hasQueued) { + if (!handle.inbox.hasWakingQueued) { handle.setStatus('idle') continue } @@ -266,7 +269,9 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> { if (!terminalStopped) handle.inbox.enqueue(message) } - if (!handle.inbox.hasQueued) handle.setStatus('idle') + // Park at idle unless a waking item still wants the model to run; a lone + // quiet (`wakeup:false`) item stays queued but does not keep the loop busy. + if (!handle.inbox.hasWakingQueued) handle.setStatus('idle') } } @@ -282,7 +287,10 @@ async function runTurn( for (const message of messages) { events.emit('agent/inbox/dequeue', agentMessage(message, true)) const prepared = preparePromptMessage(message.content, message.source, message.contexts) - session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' }) + session.append('steering/message', { + turn, ...prepared.data, + ...message.meta === undefined ? {} : { meta: message.meta }, + }, { surfaceOp: 'append' }) for (const context of prepared.separateContexts) { session.append('user/message', { content: context.content, @@ -364,7 +372,10 @@ async function runTurn( // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. const content = promptDecision.content ?? message.content const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? []) - session.append('user/message', prepared.data, { surfaceOp: 'append' }) + session.append('user/message', { + ...prepared.data, + ...message.meta === undefined ? {} : { meta: message.meta }, + }, { surfaceOp: 'append' }) // Separate contexts still enter THIS turn through inject(). Prefix // contexts are already baked into the user/message with their durable // display envelope, so appending them again would duplicate model input. @@ -543,10 +554,15 @@ async function runTurn( // enqueue event a public steer would, so the inbox ledger stays balanced // (every FIFO entry has a matching enqueue before its dequeue/discard). if (decision.action === 'continue' && decision.reason) { - const item: InboxMessage = { - id: AgentMessageId(randomUUID()), content: decision.reason.content, - source: decision.reason.source, contexts: [], wakeup: true, - } + // Detach and freeze the listener-owned reason like a public steer, so an + // enqueue listener or the producer cannot mutate the durable/model-visible + // steering message before it drains. + const item: InboxMessage = deepFreeze({ + id: AgentMessageId(randomUUID()), + content: structuredClone(decision.reason.content), + source: structuredClone(decision.reason.source), + contexts: [], wakeup: true, + }) handle.inbox.steer(item) events.emit('agent/inbox/enqueue', agentMessage(item, true)) } @@ -572,7 +588,13 @@ async function runTurn( if (terminalStop) { terminalStopped = true // Terminal stop discards steering but preserves ordinary queued prompts. - handle.inbox.drainSteering() + // Publish a discard for every dropped steering item so the enqueue ⇒ + // dequeue-or-discard ledger stays balanced (the outstanding-count + // invariant and correlation consumers must not be left with dangling ids). + const dropped = handle.inbox.drainSteering() + if (dropped.length > 0) { + events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true))) + } shouldContinue = false } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index b7d48ffd68..3a7af2871f 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -117,6 +117,38 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['preserved', 'wake it']) }) + it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // A quiet item alone must NOT wake the driver: no turn runs and whenIdle + // resolves (the agent is quiescent), leaving the item queued. + agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + await agent.whenIdle() + expect(agent.status).toBe('idle') + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + + // A later waking send drives the loop, and the quiet item rides along first. + send(agent, 'wake') + await waitForIdle(ctx, agent) + expect(userTexts(agent)).toEqual(['quiet', 'wake']) + }) + + it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => { + const adapter = new MockAdapter([textResponse('reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + const idle = agent.whenIdle() + // Cancel reaches quiescence with no status transition and no waking send; + // whenIdle must still resolve (previously it hung until the next send). + agent.cancel({ kind: 'user' }) + await idle + expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + }) + it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts index ac84c3d157..1d3e5b0360 100644 --- a/packages/core/agent-loop/tests/inbox-invariant.spec.ts +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -86,4 +86,35 @@ describe('inbox FIFO-conservation invariant', () => { expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) }) + + it('stays balanced when a terminal stop discards pending steering', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + const discards: number[] = [] + ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) + + // A continuation reason enqueues a steering item; a terminal stop then drops + // it. The drop must emit a discard so the enqueue ⇒ dequeue-or-discard + // ledger stays balanced (no dangling outstanding id). + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { + if (subject !== agent) return next() + return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } + }) + let stopped = false + ctx.on('agent/turn-stop', (subject) => { + if (subject !== agent || stopped) return undefined + stopped = true + return { action: 'stop' as const } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(discards).toEqual([1]) // the dropped steering item was reported + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 439611d12b..8d3016d872 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -526,6 +526,28 @@ describe('agent loop', () => { expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) + it('preserves SendOptions.meta on the durable user/message and steering/message', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) + const ctx = await harness(adapter) + ctx.tools.register(defineContentToolFixture({ + name: 'noop', description: '', parameters: {}, + async execute() { + // Running steer carries its own meta onto the durable steering/message. + agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'p' }, meta: { steer: 1 } }) + return [] + }, + })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }], { target: 'next-turn', wakeup: true, meta: { prompt: 1 } }) + await waitForIdle(ctx, agent) + + const user = agent.session.events.find(e => e.type === 'user/message') + expect(user?.type === 'user/message' && user.data.meta).toEqual({ prompt: 1 }) + const steering = agent.session.events.find(e => e.type === 'steering/message') + expect(steering?.type === 'steering/message' && steering.data.meta).toEqual({ steer: 1 }) + }) + it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => { // force-continue: model never calls tools, but a plugin forces 3 steps const adapter = new MockAdapter([ diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 7c61b9395d..38cba69427 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -30,8 +30,35 @@ function quote(value: string): string { } /** - * Collect exported interface and type shapes; omit names declared in multiple - * packages rather than risk serving the wrong package's shape. + * Reduce an exported class to its type shape: drop method/constructor bodies + * and property initializers so the catalog serves member signatures, not + * implementation. An abstract class (e.g. `Agent`) is a public type consumers + * program against, so it belongs in the type closure alongside interfaces. + */ +function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { + const members = node.members.map((member): ts.ClassElement => { + if (ts.isMethodDeclaration(member)) { + return ts.factory.updateMethodDeclaration( + member, member.modifiers, member.asteriskToken, member.name, member.questionToken, + member.typeParameters, member.parameters, member.type, undefined) + } + if (ts.isConstructorDeclaration(member)) { + return ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined) + } + if (ts.isPropertyDeclaration(member)) { + return ts.factory.updatePropertyDeclaration( + member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined) + } + return member + }) + return ts.factory.updateClassDeclaration( + node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) +} + +/** + * Collect exported interface, type-alias, and (body-stripped) class shapes; + * omit names declared in multiple packages rather than risk serving the wrong + * package's shape. */ function collectTypeDecls(scanRoot: string = root): Map<string, string> { const printer = ts.createPrinter({ removeComments: true }) @@ -41,14 +68,16 @@ function collectTypeDecls(scanRoot: string = root): Map<string, string> { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { - if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue + const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) + if (!named || stmt.name === undefined) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } - const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') + const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt + const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) From 0e7fee95c0a5a981f27a564eaeaa9d9ef032f25e Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Thu, 23 Jul 2026 22:08:58 +0800 Subject: [PATCH 048/113] fix(sqlite): preserve session metadata contracts --- .../session-persistence-sqlite/README.md | 4 +-- .../session-persistence-sqlite/src/schema.ts | 27 +++++++++++-------- .../tests/sqlite.spec.ts | 20 +++++++++++++- .../session-persistence/tests/contract.ts | 14 ++++++++++ .../session-query-sqlite/src/schema.ts | 6 ++--- .../session-query-sqlite/tests/sqlite.spec.ts | 16 +++++++++++ 6 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 6dcfa2d125..95256edb88 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -10,7 +10,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version. A fresh empty database is initialized at the current version; nonempty unversioned databases and every other version are rejected because this unreleased format has no migrations. Rejection occurs before changing journal mode or stamping the file. On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. @@ -55,5 +55,5 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers. - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. -- **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). +- **Only an empty new database or the current `SCHEMA_VERSION` opens** — a nonempty unversioned database or any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 8b8dcd78e0..a89b691213 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 8 +export const SCHEMA_VERSION = 9 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -63,9 +63,10 @@ export interface EventRow { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' /** - * Open the database and apply its schema and pragmas. A zero `user_version` is - * stamped with {@link SCHEMA_VERSION}; every other non-current version rejects - * rather than being migrated in place. + * Open the database and apply its schema and pragmas. An empty database with a + * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty + * unversioned database and every other non-current version reject rather than + * being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. * @returns the open handle with pragmas applied and all three tables ensured. @@ -83,17 +84,20 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void { db.exec('PRAGMA foreign_keys = ON') - // The validated union is safe to interpolate into a non-bindable PRAGMA. - db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) // `PRAGMA user_version` always returns exactly one row { user_version }. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } + const { count: userTableCount } = db.prepare( + "SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ).get() as { count: number } + if (onDisk === 0 && userTableCount > 0) { + throw new Error(`session database at "${path}" has a nonempty unversioned schema`) + } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } - if (onDisk === 0) { - // Stamp fresh or pre-versioning databases. - db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) - } + // The validated union is safe to interpolate into a non-bindable PRAGMA. + // Apply it only after rejecting incompatible existing databases. + db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) db.exec(` CREATE TABLE IF NOT EXISTS persistence_state ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), @@ -107,7 +111,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, version INTEGER NOT NULL, - created_at INTEGER NOT NULL, + created_at REAL NOT NULL, cwd TEXT, parent_session TEXT, seed_length INTEGER, @@ -128,6 +132,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM PRIMARY KEY (session_id, seq) ) STRICT `) + if (onDisk === 0) db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 3976e71549..fe8fdfb0cf 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' @@ -304,6 +305,23 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) }) + it('rejects a nonempty unversioned database before stamping or changing journal mode', async () => { + const path = await freshDbPath() + const legacy = new DatabaseSync(path) + legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)') + legacy.close() + + expect(() => openDatabase(path, 'wal')).toThrow(/nonempty unversioned schema/) + + const unchanged = new DatabaseSync(path) + expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) + expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) + expect(unchanged.prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'", + ).get()).toEqual({ name: 'sessions' }) + unchanged.close() + }) + it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { // Version 3 identified two incompatible sibling layouts, so it is always rejected. const path = await freshDbPath() @@ -442,7 +460,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(8) + expect(SCHEMA_VERSION).toBe(9) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index ae07bf77aa..c5ccca7c0d 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -84,6 +84,20 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac } }) + it('round-trips a finite fractional creation timestamp', async () => { + const { persistence, dispose } = await make() + try { + const m = { ...meta('fractional-created-at'), createdAt: 1.5 } + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) + + const loaded = await persistence.load(m.id) + expect(loaded.meta.createdAt).toBe(1.5) + } finally { + await dispose() + } + }) + it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index b88e04b536..0cb423e582 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 3 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 4 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -112,7 +112,7 @@ function ensurePersistentSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS persisted_sessions ( id TEXT PRIMARY KEY, version INTEGER NOT NULL, - created_at INTEGER NOT NULL, + created_at REAL NOT NULL, cwd TEXT, parent_session TEXT, seed_length INTEGER, @@ -141,7 +141,7 @@ function ensureTemporarySchema(db: DatabaseSync): void { CREATE TEMP TABLE IF NOT EXISTS live_sessions ( id TEXT PRIMARY KEY, version INTEGER NOT NULL, - created_at INTEGER NOT NULL, + created_at REAL NOT NULL, cwd TEXT, parent_session TEXT, seed_length INTEGER, diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 1923c6f3eb..a886f54bd4 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -167,6 +167,22 @@ async function liveContext(config: ConstructorParameters<typeof SessionQuerySqli } describe('SQLite session search', () => { + it('indexes finite fractional creation timestamps from live and persisted sources', async () => { + const persisted = header('fractional-persisted', 1.5) + TestPersistence.reset([{ meta: persisted, events: messageEvents('persisted fractional') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const live = ctx.sessions.create(SessionId('fractional-live'), { + seed: messageEvents('live fractional'), + meta: { createdAt: 2.5 }, + }) + + await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })) + .resolves.toMatchObject({ items: [{ header: { id: persisted.id, createdAt: 1.5 } }] }) + await expect(ctx.sessionQuery.searchSessions({ query: 'live' })) + .resolves.toMatchObject({ items: [{ header: { id: live.id, createdAt: 2.5 } }] }) + }) + it('searches two-character Unicode61 tokens in live-only sessions', async () => { const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) const session = ctx.sessions.create(SessionId('live'), { From 1f9a3e1bee281707da3a971c1b54e52d10dd0554 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 22:33:12 +0800 Subject: [PATCH 049/113] =?UTF-8?q?fix(agent-loop):=20second=20review=20pa?= =?UTF-8?q?ss=20=E2=80=94=20late-steering=20discard,=20dead-branch,=20cata?= =?UTF-8?q?log=20leaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address a second fresh-eye review of the review fixes: - MAJOR: late steering that lands after runTurn returns terminally stopped (e.g. during the post-turn flush) was drained by runLoop and dropped without a discard, leaving a dangling outstanding id the negative-only invariant can't catch. Emit agent/inbox/discard for it, symmetric with the in-turn terminal-stop drop. - remove the dead cancel() idle-settle branch: whenIdle's fast path already resolves for a lone quiet item, so no waiter is ever left for it to settle. Document why. - gen-cordis-api classShape now drops private/protected/#private members and strips getter/setter bodies, so Session no longer leaks private fields and getter bodies into the model catalog. - document that AgentMessage intentionally omits meta (durable-only). Adds a regression test for the late-steering discard. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +-- ...nified-send-and-coalesced-user-messages.md | 2 +- ...ied-send-and-coalesced-user-messages.zh.md | 2 +- docs/cordis-catalog/events.md | 36 +++++++++---------- docs/core-data-structures/core.md | 4 ++- docs/event-producer-consumer.md | 36 +++++++++---------- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/agent.ts | 20 ++++------- packages/core/agent-loop/src/loop.ts | 14 ++++++-- .../agent-loop/tests/inbox-invariant.spec.ts | 35 ++++++++++++++++++ packages/core/agent/src/types.ts | 4 ++- scripts/gen-cordis-api.ts | 28 +++++++++++---- 14 files changed, 122 insertions(+), 69 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 367a7e9ef5..7c3ed96243 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: e969012c8172bbba78f04943be46b7dc866ccb7f -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 35cc183a7c7bfd8a649557e50e6e7d379f8f8193 +2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index e969012c81..9eb355128b 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection. Every FIFO exit publishes exactly one lifecycle event: a terminal `agent/turn-stop` that drops pending steering now emits `agent/inbox/discard` for it, and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 35cc183a7c..679c9100aa 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性 `agent/turn-stop` 现在会为它发出 `agent/inbox/discard`,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 ## 相关 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0640893d63..56c927e0db 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:501`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -138,7 +138,7 @@ Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:325`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:412`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +376,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:488`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6ccfec950b..b35ad8ea8d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -428,7 +428,9 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t * message's enqueue, dequeue, and discard events. Source defaults are already * applied, so these are the exact values the item was accepted with. `steering` * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. + * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is + * durable model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. */ interface AgentMessage { /** The id `send` returned for this message. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 226b93f7ad..af0292af8c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:293`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:501`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:412`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:488`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index d264fe9a67..0ca32f7908 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index db7029cf50..e935daa76c 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 14a2a9ca52..f6ec27186d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1733,7 +1733,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n private log: SessionEvent[];\n private readonly surfaceManager;\n get surface(): SessionSurface {\n return this.surfaceManager;\n }\n readonly header: SessionHeader;\n get id(): SessionId {\n return this.header.id;\n }\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n private eventsSnapshot: readonly SessionEvent[] | undefined;\n get events(): readonly SessionEvent[] {\n this.eventsSnapshot ??= Object.freeze([...this.log]);\n return this.eventsSnapshot;\n }\n get seq(): number {\n return this.log.length;\n }\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n private headerFold: EpochHeader | undefined;\n private headerFoldSeq;\n requestHeader(): EpochHeader | undefined;\n private derived: Message[];\n private derivedNodes;\n private derivedGeneration;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionEvent', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 7c2dfd0f60..baa2e3f08d 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -347,11 +347,6 @@ export class ReactLoopAgent extends Agent { agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } if (!keepInbox) { - // Whether the parked driver was already scheduled to run: a waking item - // woke `waitForQueued`, so the loop WILL resume and settle idle waiters - // itself through the pre-run-cancel path (possibly after a replacement - // prompt). Only a lone quiet item leaves the loop truly parked. - const willResume = this.#inbox.hasWakingQueued // Snapshot before clearing so the discard notification carries the exact // dropped items; a replacement synchronously enqueued by an // `agent/cancel-requested` observer belongs to the next turn, not here. @@ -362,15 +357,12 @@ export class ReactLoopAgent extends Agent { const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) } - // Clearing a parked quiet (`wakeup:false`) item reaches quiescence with no - // status transition and without waking the parked driver, so settle any - // `whenIdle` waiter here. When a waking item was present the loop resumes - // and settles itself; while `running` (including the post-turn flush - // window) the driver still owns the eventual idle transition. So settle - // only for a parked, non-running agent whose sole cleared work was quiet. - if (cancellation === undefined && !willResume && this._status !== 'running') { - this.settleIdleWaiters() - } + // No idle-waiter settle here: a `whenIdle` waiter exists only while the + // agent is `running` or a waking item is queued, and neither is left + // quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s + // fast path (no waiter), a waking item keeps the woken driver running, + // and a running agent owns its own idle transition (including the + // post-turn flush window). } cancellation?.request(resolvedCause) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 86d7c106f2..4324deca8d 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -264,9 +264,17 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> { handle.clearTurnCancellation(cancellation) } - // Late steering becomes queued input unless terminal policy stopped the turn. - for (const message of handle.inbox.drainSteering()) { - if (!terminalStopped) handle.inbox.enqueue(message) + // Late steering (arriving after runTurn returns, e.g. during the post-turn + // flush) becomes queued input — unless terminal policy stopped the turn, in + // which case it is dropped and must publish a discard so its enqueue is + // still matched (the invariant only catches a NEGATIVE count, not a leak). + const lateSteering = handle.inbox.drainSteering() + if (terminalStopped) { + if (lateSteering.length > 0) { + events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true))) + } + } else { + for (const message of lateSteering) handle.inbox.enqueue(message) } // Park at idle unless a waking item still wants the model to run; a lone diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts index 1d3e5b0360..b1634afdb9 100644 --- a/packages/core/agent-loop/tests/inbox-invariant.spec.ts +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -117,4 +117,39 @@ describe('inbox FIFO-conservation invariant', () => { expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) }) + + it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + let enqueues = 0 + const discards: number[] = [] + ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 }) + ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) }) + + // Terminal-stop the turn, then steer during the post-turn flush window + // (status is still running). That late steer is drained by runLoop and + // dropped because the turn terminally stopped; it must still be discarded so + // its enqueue is matched (the drain sits on a different code path than the + // in-turn terminal-stop drop). + ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined)) + let steered = false + ctx.on('session/flush', (session) => { + if (session !== agent.session || steered) return + steered = true + agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } }) + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + // The prompt plus the late steer both enqueued; both are matched (the prompt + // dequeued, the late steer discarded) so no id is left outstanding. + expect(enqueues).toBe(2) + expect(discards).toEqual([1]) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) + expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false) + }) }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 9986affe6b..d542c39810 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -89,7 +89,9 @@ export function AgentMessageId(id: string): AgentMessageId { * message's enqueue, dequeue, and discard events. Source defaults are already * applied, so these are the exact values the item was accepted with. `steering` * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. + * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is + * durable model-hidden state that lands on the eventual `user/message`/ + * `steering/message`, not live-event routing data. */ export interface AgentMessage { /** The id `send` returned for this message. */ diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 38cba69427..9aedf69aca 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -36,20 +36,34 @@ function quote(value: string): string { * program against, so it belongs in the type closure alongside interfaces. */ function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { - const members = node.members.map((member): ts.ClassElement => { + const isNonPublic = (member: ts.ClassElement): boolean => + (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => + m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false + const members = node.members.flatMap((member): ts.ClassElement[] => { + // A model-facing type shape carries only the public surface — drop private, + // protected, and #private members, and strip every kept member's body. + if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] if (ts.isMethodDeclaration(member)) { - return ts.factory.updateMethodDeclaration( + return [ts.factory.updateMethodDeclaration( member, member.modifiers, member.asteriskToken, member.name, member.questionToken, - member.typeParameters, member.parameters, member.type, undefined) + member.typeParameters, member.parameters, member.type, undefined)] } if (ts.isConstructorDeclaration(member)) { - return ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined) + return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] + } + if (ts.isGetAccessorDeclaration(member)) { + return [ts.factory.updateGetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, member.type, undefined)] + } + if (ts.isSetAccessorDeclaration(member)) { + return [ts.factory.updateSetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, undefined)] } if (ts.isPropertyDeclaration(member)) { - return ts.factory.updatePropertyDeclaration( - member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined) + return [ts.factory.updatePropertyDeclaration( + member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] } - return member + return [member] }) return ts.factory.updateClassDeclaration( node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) From 7b7f793ee574026113f8f30c0f5c301af9557096 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 23 Jul 2026 22:57:39 +0800 Subject: [PATCH 050/113] test: fix CI coverage + e2e for user/message coalescing - tui.spec: exercise a goal-sourced injected context card (labels by source kind, not plugin name), closing the last uncovered branch in tui/src/index.ts that the CI coverage gate caught. - time-context.e2e / goal.e2e: filter injected context by source now that it is a user/message (plugin/goal source), and count goal continuation rounds by round>0 rather than event type. --- packages/context/time-context/tests/time-context.e2e.ts | 3 ++- packages/goal/goal/tests/goal.e2e.ts | 4 +++- packages/ui/tui/tests/tui.spec.ts | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 13edcfb036..02704d3eba 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -48,7 +48,8 @@ describe('time-context through a real headless cordis.yml', () => { expect(stderr).not.toContain('UNHANDLED') expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - const contexts = events.filter(event => event.type === 'user/message') + const contexts = events.filter( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') const starts = events.filter(event => event.type === 'step/start') expect(contexts).toHaveLength(2) expect(starts).toHaveLength(2) diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts index 357bebe227..756073083a 100644 --- a/packages/goal/goal/tests/goal.e2e.ts +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -69,7 +69,9 @@ describe('goal domain through a real cordis.yml and headless process', () => { }) expect(context.data.content).toEqual(renderGoalChange(change)) expect(JSON.stringify(context)).not.toContain('activation') + // No admitted continuation round ran (the snapshot mounts without starting + // a round); the round-zero state change from create is expected above. expect(events.filter(event => event.type === 'user/message' - && event.data.source.kind === 'goal')).toHaveLength(0) + && event.data.source.kind === 'goal' && event.data.source.round > 0)).toHaveLength(0) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 9d6a2bed4f..f5b4763ef6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -395,6 +395,9 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) + // A non-plugin injected source (goal) has no `plugin` field, so its context + // card label falls back to the source kind. + result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) @@ -475,6 +478,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') + expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.progress).toContain(true) From 19b96037f685e7fda01bb4906be89bfbf8287091 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Fri, 24 Jul 2026 10:35:09 +0800 Subject: [PATCH 051/113] fix(sqlite): enforce integer session metadata --- .../2026-06-14-session-persistence.md | 8 +- ...026-07-10-sqlite-session-query-provider.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/persistence.md | 2 +- packages/core/session/src/index.ts | 6 +- packages/core/session/src/types.ts | 2 +- packages/core/session/tests/session.spec.ts | 9 +- .../session-persistence-jsonl/src/format.ts | 2 + .../tests/jsonl.spec.ts | 15 +++ .../session-persistence-sqlite/README.md | 6 +- .../session-persistence-sqlite/src/schema.ts | 110 ++++++++++------- .../tests/sqlite.spec.ts | 111 +++++++++++++++++- .../session-persistence/src/coordinator.ts | 3 + .../session-persistence/tests/contract.ts | 12 +- .../session-query-sqlite/src/schema.ts | 6 +- .../session-query-sqlite/tests/sqlite.spec.ts | 16 --- 16 files changed, 228 insertions(+), 84 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 4e105ef598..85b7f3a60a 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -19,16 +19,16 @@ Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. - **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. -- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. -- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) +- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation. +- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, JSONL validates the decoded header, and SQLite stores it in a strict `INTEGER` column. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered -Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. +Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences -Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. +Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open. diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md index ff57904358..de005e4cd9 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -36,7 +36,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. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5d6d88d5c..5ed654faec 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1238,7 +1238,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:607`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f45eb0417a..740fab5f4d 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -53,7 +53,7 @@ interface SessionHeader { readonly version: number /** The session's id (mirrors the {@link Session}'s id). */ readonly id: SessionId - /** Unix epoch milliseconds when the session was created. */ + /** Non-negative safe-integer Unix epoch milliseconds when the session was created. */ readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9c5831bb18..53cefb2409 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -129,8 +129,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe if (record.id !== id) { throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`) } - if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) { - throw new Error('session header createdAt must be a finite number') + if (typeof record.createdAt !== 'number' + || !Number.isSafeInteger(record.createdAt) + || record.createdAt < 0) { + throw new Error('session header createdAt must be a non-negative safe integer') } if (record.cwd !== undefined) { if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string') diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 8b3a1e8cb6..fc63d4021e 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -36,7 +36,7 @@ export interface SessionHeader { readonly version: number /** The session's id (mirrors the {@link Session}'s id). */ readonly id: SessionId - /** Unix epoch milliseconds when the session was created. */ + /** Non-negative safe-integer Unix epoch milliseconds when the session was created. */ readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d880153dd3..b22f825a81 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -757,7 +757,7 @@ describe('Session', () => { { header: 1, error: /not a plain JSON record/ }, { header: null, error: /not a plain JSON record/ }, { header: { ...base, version: 1 }, error: /header version/ }, - { header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ }, + { header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ }, { header: { ...base, cwd: 1 }, error: /header cwd must be a string/ }, { header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ }, { header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ }, @@ -962,7 +962,7 @@ describe('SessionStore', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('plain')) expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' }) - expect(typeof session.header.createdAt).toBe('number') + expect(Number.isSafeInteger(session.header.createdAt)).toBe(true) expect(session.header.cwd).toBeUndefined() expect(session.header.parentSession).toBeUndefined() }) @@ -1001,7 +1001,10 @@ describe('SessionStore', () => { { meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ }, { meta: { cwd: 1 }, error: /header cwd must be a string/ }, { meta: { parentSession: 1 }, error: /header parentSession must be a string/ }, - { meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ }, + { meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ }, + { meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ }, + { meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ }, + { meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ }, { meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, { meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, { meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 2a34a1ce80..72228c8506 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -84,6 +84,8 @@ function isHeaderLine(value: unknown): value is HeaderLine { && typeof (value as { version?: unknown }).version === 'number' && typeof (value as { id?: unknown }).id === 'string' && typeof (value as { createdAt?: unknown }).createdAt === 'number' + && Number.isSafeInteger((value as { createdAt: number }).createdAt) + && (value as { createdAt: number }).createdAt >= 0 && typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number' && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) && (value as { delegationDepth: number }).delegationDepth >= 0 diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 2b49b7d55b..f962a8d515 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -559,6 +559,21 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/) }) + it.each([ + ['fractional', 1.5], + ['negative', -1], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ])('rejects a session header with a %s createdAt', (_label, createdAt) => { + const log = JSON.stringify({ + type: 'session', + version: 0, + id: 'invalid-created-at', + createdAt, + delegationDepth: 0, + }) + '\n' + expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) + }) + it.each([ ['missing', undefined], ['a string', '1'], diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index ecc59ffb87..011926d4b9 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,9 +8,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version. A fresh empty database is initialized at the current version; nonempty unversioned databases and every other version are rejected because this unreleased format has no migrations. Rejection occurs before changing journal mode or stamping the file. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation because this unreleased format has no migrations. On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. @@ -55,5 +55,5 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers. - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. -- **Only an empty new database or the current `SCHEMA_VERSION` opens** — a nonempty unversioned database or any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). +- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index a89b691213..395caefb13 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,10 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 9 +export const SCHEMA_VERSION = 10 + +/** SQLite application id protecting unrelated databases from persistence writes. */ +export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -86,53 +89,77 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM db.exec('PRAGMA foreign_keys = ON') // `PRAGMA user_version` always returns exactly one row { user_version }. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } - const { count: userTableCount } = db.prepare( - "SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } + const { count: userObjectCount } = db.prepare( + "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'", ).get() as { count: number } - if (onDisk === 0 && userTableCount > 0) { - throw new Error(`session database at "${path}" has a nonempty unversioned schema`) + if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { + throw new Error(`session database at "${path}" has an unversioned schema or application identity`) } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } + if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { + throw new Error( + `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`, + ) + } // The validated union is safe to interpolate into a non-bindable PRAGMA. // Apply it only after rejecting incompatible existing databases. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) - db.exec(` - CREATE TABLE IF NOT EXISTS persistence_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - store_id TEXT NOT NULL - ) STRICT - `) - db.prepare( - 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)', - ).run(randomUUID()) - db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - version INTEGER NOT NULL, - created_at REAL NOT NULL, - cwd TEXT, - parent_session TEXT, - seed_length INTEGER, - delegation_depth INTEGER, - incarnation TEXT NOT NULL, - revision INTEGER NOT NULL - ) STRICT - `) - db.exec(` - CREATE TABLE IF NOT EXISTS events ( - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - time INTEGER NOT NULL, - data TEXT NOT NULL, - source_event_seqs TEXT, - surface_op TEXT, - PRIMARY KEY (session_id, seq) - ) STRICT - `) - if (onDisk === 0) db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) + let began = false + try { + db.exec('BEGIN IMMEDIATE') + began = true + db.exec(` + CREATE TABLE IF NOT EXISTS persistence_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + store_id TEXT NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + cwd TEXT, + parent_session TEXT, + seed_length INTEGER, + delegation_depth INTEGER, + incarnation TEXT NOT NULL, + revision INTEGER NOT NULL + ) STRICT; + + CREATE TABLE IF NOT EXISTS events ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + time INTEGER NOT NULL, + data TEXT NOT NULL, + source_event_seqs TEXT, + surface_op TEXT, + PRIMARY KEY (session_id, seq) + ) STRICT + `) + db.prepare( + 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)', + ).run(randomUUID()) + if (onDisk === 0) { + db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`) + db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) + } + db.exec('COMMIT') + } catch (error: unknown) { + /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */ + if (began) { + /* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */ + try { + db.exec('ROLLBACK') + } catch { + // The original SQLite failure remains the actionable cause. + } + } + throw error + } } /** @@ -141,6 +168,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM * @returns the header, `NULL` columns mapped to omitted optional fields. */ export function rowToMeta(row: SessionRow): SessionHeader { + if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) { + throw new Error('stored session createdAt must be a non-negative safe integer') + } return { version: row.version, id: row.id as SessionId, diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index fe8fdfb0cf..886a9ff91f 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -8,7 +8,14 @@ import { DatabaseSync } from 'node:sqlite' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' -import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts' +import { + openDatabase, + rowToEvent, + rowToMeta, + scanRows, + SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, + type EventRow, +} from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -151,6 +158,22 @@ describe('scanRows', () => { }) }) +describe('rowToMeta', () => { + it('rejects fractional stored creation metadata', () => { + expect(() => rowToMeta({ + id: 'fractional', + version: 0, + created_at: 1.5, + cwd: null, + parent_session: null, + seed_length: null, + incarnation: 'fractional', + revision: 1, + delegation_depth: null, + })).toThrow('stored session createdAt must be a non-negative safe integer') + }) +}) + describe('SessionPersistenceSqlite: durability and crash semantics', () => { it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { const path = await freshDbPath() @@ -305,13 +328,13 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) }) - it('rejects a nonempty unversioned database before stamping or changing journal mode', async () => { + it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => { const path = await freshDbPath() const legacy = new DatabaseSync(path) legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)') legacy.close() - expect(() => openDatabase(path, 'wal')).toThrow(/nonempty unversioned schema/) + expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/) const unchanged = new DatabaseSync(path) expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) @@ -322,6 +345,86 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { unchanged.close() }) + it('rejects view-only and foreign-application unversioned databases without mutation', async () => { + const viewPath = await freshDbPath() + const viewOnly = new DatabaseSync(viewPath) + viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value') + viewOnly.close() + + expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/) + const unchangedView = new DatabaseSync(viewPath) + expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) + expect(unchangedView.prepare( + "SELECT type FROM sqlite_schema WHERE name = 'foreign_view'", + ).get()).toEqual({ type: 'view' }) + unchangedView.close() + + const applicationPath = await freshDbPath() + const foreignApplication = new DatabaseSync(applicationPath) + foreignApplication.exec('PRAGMA application_id = 12345') + foreignApplication.close() + + expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/) + const unchangedApplication = new DatabaseSync(applicationPath) + expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 }) + expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) + expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) + unchangedApplication.close() + }) + + it('rejects a current-version database with a foreign application identity', async () => { + const path = await freshDbPath() + const foreign = new DatabaseSync(path) + foreign.exec('PRAGMA application_id = 12345') + foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) + foreign.close() + + expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/) + + const unchanged = new DatabaseSync(path) + expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 }) + expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) + expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) + unchanged.close() + }) + + it('rolls back schema objects and identity stamps when initialization fails', async () => { + const path = await freshDbPath() + const conflicting = new DatabaseSync(path) + conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`) + conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) + conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id") + conflicting.close() + + expect(() => openDatabase(path, 'wal')).toThrow() + + const unchanged = new DatabaseSync(path) + expect(unchanged.prepare( + "SELECT type FROM sqlite_schema WHERE name = 'persistence_state'", + ).get()).toEqual({ type: 'view' }) + expect(unchanged.prepare( + "SELECT type FROM sqlite_schema WHERE name = 'sessions'", + ).get()).toBeUndefined() + expect(unchanged.prepare( + "SELECT type FROM sqlite_schema WHERE name = 'events'", + ).get()).toBeUndefined() + expect(unchanged.prepare('PRAGMA application_id').get()) + .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID }) + expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) + unchanged.close() + }) + + it('stamps the persistence application identity with the schema version', async () => { + const path = await freshDbPath() + openDatabase(path, 'wal').close() + + const db = new DatabaseSync(path) + expect(db.prepare('PRAGMA application_id').get()) + .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID }) + expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) + db.close() + }) + it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { // Version 3 identified two incompatible sibling layouts, so it is always rejected. const path = await freshDbPath() @@ -460,7 +563,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(9) + expect(SCHEMA_VERSION).toBe(10) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index fb46aa4877..089a4f7d83 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -178,6 +178,9 @@ export class PersistenceCoordinator<TornMarker = unknown> { if (snapshot === undefined) { return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable')) } + if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) { + return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer')) + } return this.serialize(snapshot.id, () => this.createCore(snapshot)) } diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index c5ccca7c0d..cf72f4d028 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -84,15 +84,17 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac } }) - it('round-trips a finite fractional creation timestamp', async () => { + it('rejects a fractional creation timestamp without reserving its session id', async () => { const { persistence, dispose } = await make() try { const m = { ...meta('fractional-created-at'), createdAt: 1.5 } - await persistence.create(m) - await persistence.append(m.id, oneTurnLog()) + await expect(persistence.create(m)) + .rejects.toThrow('session metadata createdAt must be a non-negative safe integer') - const loaded = await persistence.load(m.id) - expect(loaded.meta.createdAt).toBe(1.5) + const valid = meta('fractional-created-at') + await persistence.create(valid) + await persistence.append(valid.id, oneTurnLog()) + expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt) } finally { await dispose() } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 0cb423e582..9d92247708 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 4 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -112,7 +112,7 @@ function ensurePersistentSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS persisted_sessions ( id TEXT PRIMARY KEY, version INTEGER NOT NULL, - created_at REAL NOT NULL, + created_at INTEGER NOT NULL, cwd TEXT, parent_session TEXT, seed_length INTEGER, @@ -141,7 +141,7 @@ function ensureTemporarySchema(db: DatabaseSync): void { CREATE TEMP TABLE IF NOT EXISTS live_sessions ( id TEXT PRIMARY KEY, version INTEGER NOT NULL, - created_at REAL NOT NULL, + created_at INTEGER NOT NULL, cwd TEXT, parent_session TEXT, seed_length INTEGER, diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index a886f54bd4..1923c6f3eb 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -167,22 +167,6 @@ async function liveContext(config: ConstructorParameters<typeof SessionQuerySqli } describe('SQLite session search', () => { - it('indexes finite fractional creation timestamps from live and persisted sources', async () => { - const persisted = header('fractional-persisted', 1.5) - TestPersistence.reset([{ meta: persisted, events: messageEvents('persisted fractional') }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - const live = ctx.sessions.create(SessionId('fractional-live'), { - seed: messageEvents('live fractional'), - meta: { createdAt: 2.5 }, - }) - - await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })) - .resolves.toMatchObject({ items: [{ header: { id: persisted.id, createdAt: 1.5 } }] }) - await expect(ctx.sessionQuery.searchSessions({ query: 'live' })) - .resolves.toMatchObject({ items: [{ header: { id: live.id, createdAt: 2.5 } }] }) - }) - it('searches two-character Unicode61 tokens in live-only sessions', async () => { const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) const session = ctx.sessions.create(SessionId('live'), { From 352934b9eca194006bc2e7cdeca0467edc89959d Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Fri, 24 Jul 2026 11:01:08 +0800 Subject: [PATCH 052/113] fix(jsonl): reject negative-zero timestamps --- .../session-persistence-jsonl/src/format.ts | 1 + .../session-persistence-jsonl/tests/jsonl.spec.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 72228c8506..48f99e6610 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -86,6 +86,7 @@ function isHeaderLine(value: unknown): value is HeaderLine { && typeof (value as { createdAt?: unknown }).createdAt === 'number' && Number.isSafeInteger((value as { createdAt: number }).createdAt) && (value as { createdAt: number }).createdAt >= 0 + && !Object.is((value as { createdAt: number }).createdAt, -0) && typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number' && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) && (value as { delegationDepth: number }).delegationDepth >= 0 diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index f962a8d515..5e9f446379 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -574,6 +574,11 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) }) + it('rejects a session header with negative-zero createdAt', () => { + const log = '{"type":"session","version":0,"id":"invalid-created-at","createdAt":-0,"delegationDepth":0}\n' + expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) + }) + it.each([ ['missing', undefined], ['a string', '1'], From e54a4ad98699b18f31f6c1b58178a928b8d6dfe0 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Fri, 24 Jul 2026 11:11:45 +0800 Subject: [PATCH 053/113] fix(sqlite): match reserved object names literally --- .../session-persistence-sqlite/src/schema.ts | 2 +- .../tests/sqlite.spec.ts | 17 ++++++++++++++++ .../session-query-sqlite/src/schema.ts | 2 +- .../session-query-sqlite/tests/sqlite.spec.ts | 20 +++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 395caefb13..a078a43c8b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -91,7 +91,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } const { count: userObjectCount } = db.prepare( - "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'", + "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'", ).get() as { count: number } if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { throw new Error(`session database at "${path}" has an unversioned schema or application identity`) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 886a9ff91f..a7f3ae2f05 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -345,6 +345,23 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { unchanged.close() }) + it('counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata', async () => { + const path = await freshDbPath() + const unrelated = new DatabaseSync(path) + unrelated.exec('CREATE TABLE sqliteX (value TEXT)') + unrelated.exec("INSERT INTO sqliteX VALUES ('safe')") + unrelated.close() + + expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/) + + const unchanged = new DatabaseSync(path) + expect(unchanged.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' }) + expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 }) + expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) + expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) + unchanged.close() + }) + it('rejects view-only and foreign-application unversioned databases without mutation', async () => { const viewPath = await freshDbPath() const viewOnly = new DatabaseSync(viewPath) diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 9d92247708..47f6374ba6 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -78,7 +78,7 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode) function listUserTables(db: DatabaseSync): string[] { const rows = db.prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' ORDER BY name", ).all() as Array<{ name: string }> return rows.map(row => row.name) } diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 1923c6f3eb..1812a85428 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1148,6 +1148,26 @@ describe('SQLite schema, cancellation, and real persistence integration', () => expect(stillForeign.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' }) stillForeign.close() + const wildcardPath = await temporaryPath('sqlite-wildcard.db') + const wildcard = new DatabaseSync(wildcardPath) + wildcard.exec('PRAGMA journal_mode = WAL') + wildcard.exec('CREATE TABLE sqliteX(value TEXT)') + wildcard.exec("INSERT INTO sqliteX VALUES ('safe')") + wildcard.close() + const wildcardCtx = new Context() + await wildcardCtx.plugin(SessionStore) + await expect(wildcardCtx.plugin(SessionQuerySqlite, { + path: wildcardPath, + journalMode: 'delete', + })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + expect(wildcardCtx.sessionQuery).toBeUndefined() + const stillWildcard = new DatabaseSync(wildcardPath) + expect(stillWildcard.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' }) + expect(stillWildcard.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 }) + expect(stillWildcard.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 }) + expect(stillWildcard.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' }) + stillWildcard.close() + const otherAppPath = await temporaryPath('other-app.db') const otherApp = new DatabaseSync(otherAppPath) otherApp.exec('PRAGMA application_id = 123') From dc2ea3a1b4979dab114fdb27f5c5e72515183b65 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Fri, 24 Jul 2026 11:20:47 +0800 Subject: [PATCH 054/113] fix(sqlite): lock before ownership validation --- .../session-persistence-sqlite/src/schema.ts | 42 ++++++++++--------- .../tests/sqlite.spec.ts | 1 + 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index a078a43c8b..754d9d7e63 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -87,30 +87,28 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void { db.exec('PRAGMA foreign_keys = ON') - // `PRAGMA user_version` always returns exactly one row { user_version }. - const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } - const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } - const { count: userObjectCount } = db.prepare( - "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'", - ).get() as { count: number } - if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { - throw new Error(`session database at "${path}" has an unversioned schema or application identity`) - } - if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { - throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) - } - if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { - throw new Error( - `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`, - ) - } - // The validated union is safe to interpolate into a non-bindable PRAGMA. - // Apply it only after rejecting incompatible existing databases. - db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) let began = false try { db.exec('BEGIN IMMEDIATE') began = true + // Validate while holding the write lock so no other connection can change + // schema ownership between inspection and initialization. + const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } + const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } + const { count: userObjectCount } = db.prepare( + "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'", + ).get() as { count: number } + if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { + throw new Error(`session database at "${path}" has an unversioned schema or application identity`) + } + if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { + throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) + } + if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { + throw new Error( + `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`, + ) + } db.exec(` CREATE TABLE IF NOT EXISTS persistence_state ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), @@ -148,6 +146,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) } db.exec('COMMIT') + began = false } catch (error: unknown) { /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */ if (began) { @@ -160,6 +159,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM } throw error } + // The validated union is safe to interpolate into a non-bindable PRAGMA. + // Apply it only after ownership validation and initialization commit. + db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a7f3ae2f05..8a47c7917f 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -428,6 +428,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(unchanged.prepare('PRAGMA application_id').get()) .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID }) expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION }) + expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' }) unchanged.close() }) From b9f8eca10cad0e8ecc4936a31d7d63064918cdc8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 24 Jul 2026 11:23:16 +0800 Subject: [PATCH 055/113] fix(python-sdk): track recursive subagent notifications --- ...python-sdk-session-notifications.i18n.yaml | 6 ++ ...ursive-python-sdk-session-notifications.md | 27 ++++++ ...ive-python-sdk-session-notifications.zh.md | 27 ++++++ python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 4 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 5 +- python/sdk/src/deepseek_harness/client.py | 64 +++++++++++--- python/sdk/tests/test_client.py | 86 +++++++++++++++++++ 9 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml new file mode 100644 index 0000000000..0e8626c838 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-recursive-python-sdk-session-notifications.md: c608cfa584e568602d599c7e18ffc36fabbd0186 +2026-07-24-recursive-python-sdk-session-notifications.zh.md: 397669cc13a5c77dabce55209be3cd2f0c7a82ea diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md new file mode 100644 index 0000000000..c608cfa584 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md @@ -0,0 +1,27 @@ +# Agent Note: Recursive Python SDK session notifications + +Status: implemented + +English | [中文](2026-07-24-recursive-python-sdk-session-notifications.zh.md) + +## Problem + +The Python SDK filtered turn notifications by comparing each payload directly with the root session id. This admitted a direct child's lifecycle because its parent id named the root, but rejected a grandchild's lifecycle and every descendant `session.event`. The JSON-RPC server still emitted those notifications, so they accumulated on the low-level global queue while high-level consumers lost nested trajectory relationships and completion states. + +## Decision + +`HarnessClient` records every valid `subagent.started` and `subagent.finished` child-to-parent edge before dispatching the notification. Session subscriptions classify each payload session id, parent id, and child id by walking that client-lifetime ancestry graph to their requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. + +`Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response. + +## Alternatives considered + +**Add a root session id to every JSON-RPC notification.** The server already provides exact immediate-parent edges, and duplicating transitive ancestry on the wire would make every producer responsible for client subscription state. + +**Limit subagents to one level.** A deployment can set `maxDepth: 1`, but changing the SDK to depend on that policy would silently misreport valid recursive compositions. + +**Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree. + +## Consequences + +High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, and ancestry reuse across subscriptions. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md new file mode 100644 index 0000000000..397669cc13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Python SDK 递归会话通知 + +Status: implemented + +[English](2026-07-24-recursive-python-sdk-session-notifications.md) | 中文 + +## 问题 + +Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较来过滤轮次通知。直接子 agent 的生命周期通知因 parent ID 指向根会话而能够通过,但孙级生命周期通知与所有后代 `session.event` 都会被拒绝。JSON-RPC 服务器仍会发出这些通知,因此它们会堆积在底层全局队列中,而高层消费者会丢失嵌套轨迹的关系与结束状态。 + +## 决策 + +`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 和 `subagent.finished` 所包含的 child-to-parent(子到父)关系。会话订阅会沿客户端生命周期内保存的祖先关系图回溯每个 payload 中的 session ID、parent ID 与 child ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 + +`Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。 + +## 考虑过的替代方案 + +**在每条 JSON-RPC 通知中加入根会话 ID。** 服务器已经提供精确的直接父子关系;在线路协议中重复传递祖先关系,会迫使每个生产者承担客户端订阅状态的职责。 + +**把 subagent 限制为一层。** 部署可以设置 `maxDepth: 1`,但让 SDK 依赖该策略,会对合法的递归组合产生静默误报。 + +**只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。 + +## 后果 + +高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积,以及跨订阅复用祖先关系。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 956d6f8ff8..ed74d60087 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b -README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534 +README.md: bfa31a712acd6fccf1458a0a80fc2ff80dfe114e +README.zh.md: 11ebcdd133b2fd839b73f50ef2be2e531e8bbc2a diff --git a/python/sdk/README.md b/python/sdk/README.md index 23d15d617b..bfa31a712a 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -34,9 +34,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. The bundled default composition registers `deepseek`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -`TurnResult.final_response` is the text content from the last -`assistant/message` event in the turn. Use `TurnResult.events` for the complete -event stream, including intermediate assistant messages and tool activity. +`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `TurnResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `TurnResult.events` remains the root session's complete event stream, and `TurnResult.final_response` is the text content from its last `assistant/message`; descendant messages therefore cannot replace the root response. The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 4f6aef1383..11ebcdd133 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -30,7 +30,7 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。内置默认组合注册 `deepseek`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -`TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 +`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications` 与 `on_notification` 会按线上的原始顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流,`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复。 同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 2b44a50c64..d96e974bc3 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -143,7 +143,10 @@ class Session: notifications.append(notification) if on_notification is not None: on_notification(notification) - if notification.method == "session.event": + if ( + notification.method == "session.event" + and notification.payload.get("sessionId") == self.id + ): event = notification.payload.get("event") if isinstance(event, dict): events.append(event) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index e552c8b685..b80d83b5f4 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -47,6 +47,7 @@ class HarnessClient: self._notification_subscribers: dict[ str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None] ] = {} + self._session_parents: dict[str, str] = {} self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue() self._stderr_lines: deque[str] = deque(maxlen=400) self._reader_thread: threading.Thread | None = None @@ -62,6 +63,8 @@ class HarnessClient: def start(self) -> None: if self._proc is not None: return + with self._lock: + self._session_parents.clear() args = list(self.config.launch_args_override or self._default_launch_args()) env = os.environ.copy() if self.config.env: @@ -143,7 +146,7 @@ class HarnessClient: payload, response_model=_SessionPromptResponse, on_notification=on_notification, - notification_filter=_notification_belongs_to_session(session_id), + notification_filter=self._notification_belongs_to_session_tree(session_id), notification_subscription=notification_subscription, ) @@ -193,7 +196,8 @@ class HarnessClient: return NotificationSubscription(self, subscription_id, notifications) def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription": - return self.subscribe_notifications(_notification_belongs_to_session(session_id)) + """Subscribe to a session and descendants discovered from subagent lifecycle edges.""" + return self.subscribe_notifications(self._notification_belongs_to_session_tree(session_id)) def next_request(self) -> IncomingRequest: item = self._requests.get() @@ -352,6 +356,7 @@ class HarnessClient: params = message.get("params") notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) with self._lock: + self._record_session_relationship_locked(notification) subscribers = list(self._notification_subscribers.items()) delivered = False for subscription_id, (subscriber, predicate) in subscribers: @@ -439,6 +444,49 @@ class HarnessClient: with self._lock: self._notification_subscribers.pop(subscription_id, None) + def _record_session_relationship_locked(self, notification: Notification) -> None: + if notification.method not in {"subagent.started", "subagent.finished"}: + return + parent_id = notification.payload.get("parentSessionId") + child_id = notification.payload.get("childSessionId") + if ( + isinstance(parent_id, str) + and parent_id + and isinstance(child_id, str) + and child_id + and parent_id != child_id + ): + self._session_parents[child_id] = parent_id + + def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter: + def belongs(notification: Notification) -> bool: + payload = notification.payload + related_ids = ( + payload.get("sessionId"), + payload.get("parentSessionId"), + payload.get("childSessionId"), + ) + return any( + isinstance(related_id, str) + and self._session_is_descendant_of(related_id, session_id) + for related_id in related_ids + ) + + return belongs + + def _session_is_descendant_of(self, session_id: str, root_session_id: str) -> bool: + current = session_id + visited: set[str] = set() + while current not in visited: + if current == root_session_id: + return True + visited.add(current) + parent = self._session_parents.get(current) + if parent is None: + return False + current = parent + return False + class NotificationSubscription: def __init__( @@ -491,15 +539,3 @@ class _ShutdownResponse(BaseModel): def _int_or_none(value: object) -> int | None: return value if isinstance(value, int) else None - - -def _notification_belongs_to_session(session_id: str) -> NotificationFilter: - def belongs(notification: Notification) -> bool: - payload = notification.payload - return ( - payload.get("sessionId") == session_id - or payload.get("parentSessionId") == session_id - or payload.get("childSessionId") == session_id - ) - - return belongs diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index f66abf4b36..fda0686980 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -198,6 +198,65 @@ for line in sys.stdin: ] +def test_session_run_collects_nested_subagent_tree_without_polluting_root_events( + tmp_path: Path, +) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + root = (msg.get("params") or {})["sessionId"] + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "grandchild", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "grandchild response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "child", "childSessionId": "grandchild", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": root, "childSessionId": "child", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "root response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": root, "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + seen: list[str] = [] + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run( + "delegate recursively", + session_id="main", + on_notification=lambda notification: seen.append(notification.method), + ) + assert harness.client._notifications.qsize() == 0 + + assert result.status == "ok" + assert result.final_response == "root response" + assert [event["data"]["content"][0]["text"] for event in result.events] == ["root response"] + assert [notification.method for notification in result.notifications] == [ + "subagent.started", + "session.event", + "subagent.started", + "session.event", + "subagent.finished", + "subagent.finished", + "session.event", + "session.finished", + ] + assert seen == [notification.method for notification in result.notifications] + + def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None: script = tmp_path / "fake_runtime.py" script.write_text( @@ -356,6 +415,33 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe assert notification.payload["sessionId"] == "other" +def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None: + client = HarnessClient() + with client.subscribe_session_notifications("main") as first: + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "main", "childSessionId": "child"}, + }) + assert first.next().payload["childSessionId"] == "child" + + with client.subscribe_session_notifications("main") as second: + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "child", "childSessionId": "grandchild"}, + }) + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "grandchild", "event": {"type": "assistant/message"}}, + }) + assert second.next().payload["childSessionId"] == "grandchild" + assert second.next().payload["sessionId"] == "grandchild" + + assert client._notifications.qsize() == 0 + + def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: script = tmp_path / "fake_bridge.py" script.write_text( From b65b9ad4a8c7a6a3762bef17d6715987db440205 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:54:23 +0800 Subject: [PATCH 056/113] test: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b0a3a3d526..9fb243459e 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", @@ -24,11 +24,11 @@ }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so the README, Agent Notes, and docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency.\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.\n2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all.\n4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named Agent Notes merge bilingual from birth.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, and `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\n- `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n\n**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An Agent Note filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so the README, Agent Notes, and docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency.\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.\n2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all.\n4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named Agent Notes merge bilingual from birth.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope, exclusions, and rollout\n\n**Scope**: the root `README.md`, everything under `.agents/notes/**`, `docs/**`, and `python/**`. Package READMEs (`packages/**`) join the scope in a later batch.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.\n- `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n\n**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An Agent Note filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README、Agent Note(agent 决策记录)与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」,从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。\n2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok),是翻译批次的工作清单。它从不失败;它只报告。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围、排除与推进\n\n**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。包(package)README(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md` 与 `docs/module-graph.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\n- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n\n**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README、Agent Note(agent 决策记录)与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」,从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。\n2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。\n4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok),是翻译批次的工作清单。它从不失败;它只报告。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围、排除与推进\n\n**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。包(package)README(`packages/**`)在后续批次加入范围。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。\n- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n\n**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", From c7c1b97501e7200f28f78fd8bedc850b15e158c4 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 12:05:57 +0800 Subject: [PATCH 057/113] =?UTF-8?q?fix(agent-loop):=20address=20second-rou?= =?UTF-8?q?nd=20review=20=E2=80=94=20disposal=20discard,=20injection=20val?= =?UTF-8?q?idation,=20frozen=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the review bot's five genuinely-new findings on the current code: - disposal now discards any still-pending inbox items before the loop exits, so every enqueued id gets a terminal lifecycle event. - injection (next-step/no-wakeup) validates its payload up front, before opening the idle one-shot turn, honoring 'invalid input throws before any append'; and rejects attached contexts (which belong only to inbox messages) rather than silently dropping them. - agentMessage() freezes the agent/inbox/* payload so a listener cannot mutate the shared correlation object mid-dispatch. - refresh the package READMEs (compact, goal, guard, hook-protocol, plan-mode, time-context, workspace-context) that still referenced the removed context/message event, with the source-based user/message distinction. The up-front injection validation makes two finally branches unreachable (v8-ignored as the turn-enclosure backstop). Adds regression tests for disposal discard, context rejection, up-front validation, and the frozen payload; per-file coverage stays 100%. --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 2 +- ...ied-send-and-coalesced-user-messages.zh.md | 2 +- packages/compact/compact/README.md | 2 +- packages/context/time-context/README.md | 4 +- packages/context/workspace-context/README.md | 8 +- packages/core/agent-loop/src/agent.ts | 74 +++++++++++-------- packages/core/agent-loop/src/inbox.ts | 9 ++- packages/core/agent-loop/tests/agent.spec.ts | 63 ++++++++++++---- packages/core/agent-loop/tests/inbox.spec.ts | 11 ++- packages/goal/goal/README.md | 2 +- packages/guard/repeat-tool-guard/README.md | 4 +- packages/hooks/hook-protocol/README.md | 2 +- packages/plan/plan-mode/README.md | 2 +- 14 files changed, 127 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 7c3ed96243..e0e804e953 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e +2026-07-22-unified-send-and-coalesced-user-messages.md: eaef3d7e8efd8362c5029d777701c5db59c1ee5b +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: f84fe22f457677b8f8463417b2931cfd0ec77508 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9eb355128b..eaef3d7e8e 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` (both at the in-turn stop point and on the post-turn drain of late steering), and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like a public send. Injection (`next-step`/no-wakeup) validates its payload up front — before opening the idle one-shot turn, honoring the "invalid input throws before any append" contract — and rejects attached `contexts` (which belong only to inbox messages) rather than silently dropping them. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 679c9100aa..f84fe22f45 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`(既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时),而 dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。注入(`next-step`/no-wakeup)会预先校验其载荷——在打开空闲状态的一次性轮次之前,从而遵守“无效输入在任何追加之前抛出”的契约——并拒绝附带的 `contexts`(`contexts` 只属于 inbox 消息),而不是静默丢弃它们。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 ## 相关 diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 03b91d653e..ff47ef3b19 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -33,7 +33,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an ## Surface contract -`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: +`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead: 1. appends `compact/start` (log-only) — acquires the lock, 2. summarizes the range, diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 2327da81b2..f4938982a1 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -18,9 +18,9 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o ## Timing semantics -The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. +The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. -Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. +Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`. diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a7245df91f..67ab9b4245 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -28,7 +28,7 @@ Instructions from: AGENTS.md </system-reminder> ``` -Newly reached scopes use a durable raw `context/message`: +Newly reached scopes use a durable injected `user/message` (plugin source): ```md <system-reminder> @@ -42,11 +42,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. +The plugin owns the complete `<system-reminder>` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. +Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. @@ -111,7 +111,7 @@ Prefix-stable within one loop instance because the baseline is frozen. A new or #### What the model sees -After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file. +After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file. ##### Additional instruction template diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index baa2e3f08d..9bc24de5da 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -248,14 +248,22 @@ export class ReactLoopAgent extends Agent { /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ private injectContext(content: ContentBlock[], options?: SendOptions): void { + // Injection is synthetic durable context, not an inbox message: attached + // contexts belong only to queued/steering sends, so reject them rather than + // silently dropping a value the option type structurally permits. + if (options?.contexts !== undefined && options.contexts.length > 0) { + throw new TypeError('agent inject (next-step/no-wakeup) does not accept attached contexts') + } const source = options?.source ?? { kind: 'plugin', plugin: '' } - const context = { + // Detach and validate the payload BEFORE any append, so malformed input + // throws without opening a one-shot turn or mutating the session (the + // unified send contract: invalid input throws before any append). + const accepted = this.acceptContext({ content, source, ...options?.meta !== undefined ? { meta: options.meta } : {}, - } + }) if (isTurnOpen(this.session)) { - const accepted = this.acceptContext(context) // Provider protocols require every assistant tool-call batch to be // followed only by its tool results. Historical interrupted batches do // not own new context; only the currently executing batch may defer it. @@ -267,39 +275,35 @@ export class ReactLoopAgent extends Agent { return } // No turn open: wrap the injection in a one-shot turn so every event stays - // turn-enclosed (the durability/replay boundary is the turn). + // turn-enclosed (the durability/replay boundary is the turn). The payload is + // validated above, so both appends commit together; the finally still owes + // a turn/end (the turn-enclosure invariant) even if a post-commit observer + // throws after turn/start. const turn = lastTurnNumber(this.session) + 1 - // Once turn/start enters the log, a turn/end is owed even if the message - // append fails acceptance or pre-commit validation. The finally re-checks - // the log and closes only a turn that actually opened; post-commit observers - // are contained by Session and cannot create a false append failure. try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('user/message', context, { surfaceOp: 'append' }) + this.session.append('user/message', accepted, { surfaceOp: 'append' }) } finally { - // Close the turn if turn/start made it into the log. A pre-commit veto - // must escape rather than being mistaken for a committed turn/end. + // Close the turn if turn/start committed. With the payload validated up + // front both appends commit together, so the turn is always open here; + // the guard remains the turn-enclosure backstop. + /* v8 ignore next -- unopened turn is unreachable after up-front validation; kept as the enclosure backstop. */ if (isTurnOpen(this.session)) { this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // Decide the durability checkpoint from the log: an accepted one-shot - // turn must be flushed even when its message append was the failing step. - const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - // Keep inject() synchronous: report checkpoint failures live instead of - // rejecting the caller, and track the task so disposal still drains it. - if (turnRecorded) { - // Through the store's flush (the carrier owner), never a raw parallel. - const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { - const rendered = errorChain(error) - const err = error instanceof Error ? error : new Error(rendered) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) - agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) - }) - this.pendingIdleFlushes.add(flush) - // Retire on either settlement path. - const retire = (): void => { this.pendingIdleFlushes.delete(flush) } - void flush.then(retire, retire) - } + // Flush the one-shot turn through the store (the carrier owner), never a + // raw parallel. Keep inject() synchronous: report checkpoint failures live + // instead of rejecting the caller, and track the task so disposal drains it. + const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { + const rendered = errorChain(error) + const err = error instanceof Error ? error : new Error(rendered) + this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) + agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) + }) + this.pendingIdleFlushes.add(flush) + // Retire on either settlement path. + const retire = (): void => { this.pendingIdleFlushes.delete(flush) } + void flush.then(retire, retire) } } @@ -434,6 +438,18 @@ export class ReactLoopAgent extends Agent { */ private [stopDriver](): Promise<void> | void { if (this._status !== 'disposed') { + // Discard any still-pending inbox items before disposal so every enqueued + // id gets a terminal lifecycle event; a disposed agent never dequeues + // them. Emitted while still published (before the status flip below), and + // only when there is a public lifecycle to observe it. + if (this.published) { + const discarded = this.#inbox.pending() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } + } + this.#inbox.clear() this._status = 'disposed' this.resolveDisposed() // Release whenIdle waiters BEFORE the (guarded) event emit — they are diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index 3cb82944e4..c0a6f4d694 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -29,7 +29,14 @@ export interface InboxMessage { * @returns the live-event message for enqueue/dequeue/discard. */ export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage { - return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup } + // Frozen: the fused emitter passes this exact object to every listener in + // turn, so one listener must not be able to mutate a field (`id`, `steering`, + // `content`, …) a later listener then observes. `message` is already a frozen + // inbox record, so its nested fields need no re-clone. + return Object.freeze({ + id: message.id, content: message.content, source: message.source, + contexts: message.contexts, steering, wakeup: message.wakeup, + }) } /** diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 608f1bad60..3b8942d27c 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -98,6 +98,27 @@ describe('Agent', () => { expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) + it('disposal discards still-pending inbox items so every id gets a terminal event', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + let agent!: Agent + const discarded: string[] = [] + ctx.on('agent/inbox/discard', (subject, messages) => { + if (subject === agent) discarded.push(...messages.map(m => m.id)) + }) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) + }, { inject: ['agentLoop'] })) + + // A quiet (non-waking) item stays parked in the inbox; disposal must drop it + // WITH a discard so its enqueued id is not left dangling forever. + const id = agent.send([{ type: 'text', text: 'never runs' }], { target: 'next-turn', wakeup: false }) + await fiber.dispose() + await driverDone(agent) + + expect(discarded).toEqual([id]) + }) + it('steer() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -177,24 +198,22 @@ describe('Agent', () => { warn.mockRestore() }) - it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { + it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // Non-serializable injected content makes Session.append throw AFTER - // turn/start was recorded. The turn/end must still be appended (finally), - // AND the durability checkpoint must still fire — the balanced turn is in - // memory and a crash before the next turn/dispose would otherwise lose it. + // Non-serializable injected content is rejected by the up-front snapshot + // BEFORE any append (the unified send contract: invalid input throws before + // mutating the log). No one-shot turn opens and no durability checkpoint fires. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) - }).toThrow(/non-JSON-serializable/) - const types = agent.session.events.map(e => e.type) - expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn - await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run - expect(flushes).toBe(1) // checkpoint fired despite the throw + }).toThrow(/losslessly JSON-serializable/) + expect(agent.session.events).toHaveLength(0) + await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance + expect(flushes).toBe(0) // nothing was appended, so no checkpoint }) it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { @@ -244,13 +263,27 @@ describe('Agent', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // A non-serializable source makes the turn/start append throw BEFORE the - // event is pushed (Session.append validates before push), so NO turn opens. - // The finally's isTurnOpen() guard sees no open turn and appends nothing — - // the log stays empty, not left with a dangling turn/start. + // A non-serializable source is rejected by the up-front snapshot BEFORE any + // append, so NO turn opens and the log stays empty. expect(() => { agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) - }).toThrow(/non-JSON-serializable/) + }).toThrow(/losslessly JSON-serializable/) + expect(agent.session.events).toHaveLength(0) + }) + + it('inject() rejects attached contexts (they belong to inbox messages, not injection)', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + // contexts structurally compile on AliasSendOptions but injection cannot + // carry them, so they are rejected rather than silently dropped. + expect(() => { + agent.inject([{ type: 'text', text: 'x' }], { + source: { kind: 'plugin', plugin: 'p' }, + contexts: [{ content: [{ type: 'text', text: 'ctx' }], source: { kind: 'plugin', plugin: 'p' } }], + } as never) + }).toThrow(/does not accept attached contexts/) expect(agent.session.events).toHaveLength(0) }) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 791eae3bda..289cd2f6d5 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from 'vitest' import { AgentMessageId } from '@deepseek-ai/dsh-agent' -import { Inbox } from '../src/inbox.ts' +import { Inbox, agentMessage } from '../src/inbox.ts' function message(text: string) { return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true } } +describe('agentMessage', () => { + it('returns a frozen payload so a listener cannot mutate it for later listeners', () => { + const payload = agentMessage(message('m'), false) + expect(Object.isFrozen(payload)).toBe(true) + expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow() + expect(payload.id).toBe(AgentMessageId('m')) + }) +}) + function resolverPair() { let r!: () => void const p = new Promise<void>((resolve) => { r = resolve }) diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index e50044d135..1df6d08f5c 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -19,7 +19,7 @@ Event-sourced same-session goal state. The service retains one current completio At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. -Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. +Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The round-zero `user/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index ef5e4e5846..e7bd79732e 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -30,11 +30,11 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de ## Reminder delivery -Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. ## Testing -Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript. +Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as injected `user/message`s in the ACP transcript. ## Model Experience diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 42a642caf0..3a84807285 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -27,7 +27,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). -Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks Agent Note. +Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `user/message` is the durable evidence) — see the hooks Agent Note. ## Model Experience diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 2725954185..0f24508d14 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state. +`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state. ## Model and human surfaces From 46ab745198d84b895462eb1e43a6651512d9ba95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:10:40 +0800 Subject: [PATCH 058/113] docs: align Goal Round step cardinality --- docs/glossary.i18n.yaml | 2 +- docs/glossary.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index e49ddd96c6..b63e41b87b 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -glossary.md: 414fb5342886ce9fc775c529a7e61f0d760e93c6 +glossary.md: 0270a2d0dba558483e8e458a932a27b0151f2c93 glossary.zh.md: ed3009a054815f1c7165fc322e44cc9521527643 diff --git a/docs/glossary.md b/docs/glossary.md index 414fb53428..0270a2d0db 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,7 +21,7 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i ## goal - **goal** — one durable completion objective attached to an existing session, with a revisioned `active` / `paused` / `blocked` / `complete` phase and a goal-round cap; `blocked` retains a policy code and explanation. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. -- **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. <a id="goal-round"></a> +- **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain zero or more steps; unrelated human turns in the same session do not consume the goal-round cap. <a id="goal-round"></a> - **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later human-authorized resume mutation through `/goal` or the model tool before automatic work. ## human command From 250162a2fca31daa1cea947788ba228ea1232a1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:11:58 +0800 Subject: [PATCH 059/113] docs: align core catalog translations --- docs/core-data-structures/core.i18n.yaml | 4 ++-- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/core.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 7a48092688..4cf6d47d23 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -core.md: 21b7c39ad7b60ab7454452a3830808c041850919 -core.zh.md: 4421d44954111fc471087d13ad7dfc94301257e2 +core.md: b0aa719974c5e5839027a6f958ce08259053da76 +core.zh.md: 12bb477686ad37cce2e98319b8d75ffefc50bbb9 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 21b7c39ad7..b0aa719974 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -454,7 +454,7 @@ interface Agent { `AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. -The cause is a TypeScript-enforced same-process input. An active holder copies its discriminant into the runtime-only `AbortSignal.reason`; it is retired before `turn/end` publication. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. +The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 4421d44954..12bb477686 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -11,7 +11,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 精确地说,一个数据结构是**核心**的,当且仅当满足以下条件之一: 1. 它流经 agent loop 主干——循环在每个轮次中持有、派生、流式输出或记录它(`Message`、`StreamChunk`、`SessionEvent`、`Agent` 句柄本身),与当前加载了哪些插件无关;**或者** -2. 它是插件作者面向某条流水线编写的唯一标题类型——`ToolDefinition`(每个工具*是什么*)。 +2. 它是插件作者面向某条流水线编写的代表性类型——`ToolDefinition`(每个工具*是什么*)。 其他一切都记录在**子页面**上,而非本页。划线的规则是:*你编写、持有或接收的类型是核心;为它提供类型推导、渲染或持久化的机制是子页面细节*。因此 `ToolDefinition` 是核心,但为它提供类型推导的 `ValueSchemaSpec`/`ParameterSchemaSpec` 机制、为它提供渲染意图的 `ToolCallView`/`ToolResultView` 词汇,以及存储事件日志的 `SessionPersistence` seam 都不是——它们分别在下列子页面中。 From 11eedca16e0ecb9e6cc53865965478ac9d74bcfd Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Fri, 24 Jul 2026 12:21:10 +0800 Subject: [PATCH 060/113] fix(python-sdk): preserve reused session ancestry --- ...python-sdk-session-notifications.i18n.yaml | 4 +- ...ursive-python-sdk-session-notifications.md | 6 +- ...ive-python-sdk-session-notifications.zh.md | 6 +- python/sdk/src/deepseek_harness/client.py | 19 +++--- python/sdk/tests/test_client.py | 61 ++++++++++++++++++- 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml index 0e8626c838..cae5b75cb4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-recursive-python-sdk-session-notifications.md: c608cfa584e568602d599c7e18ffc36fabbd0186 -2026-07-24-recursive-python-sdk-session-notifications.zh.md: 397669cc13a5c77dabce55209be3cd2f0c7a82ea +2026-07-24-recursive-python-sdk-session-notifications.md: c90213659391b565acd043a1be64e225f8babd31 +2026-07-24-recursive-python-sdk-session-notifications.zh.md: 214a5ef924dcc9da3a97aab6385837acd2b364d9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md index c608cfa584..c902136593 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md @@ -10,7 +10,7 @@ The Python SDK filtered turn notifications by comparing each payload directly wi ## Decision -`HarnessClient` records every valid `subagent.started` and `subagent.finished` child-to-parent edge before dispatching the notification. Session subscriptions classify each payload session id, parent id, and child id by walking that client-lifetime ancestry graph to their requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. +`HarnessClient` records every valid `subagent.started` child-to-parent edge before dispatching the notification. A later `subagent.finished` routes by its immutable parent id but never rewrites current ancestry, so an older run that settles after its child id has been reused cannot displace the replacement session. Other session notifications resolve their session id by walking that client-lifetime ancestry graph to the requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. `Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response. @@ -22,6 +22,8 @@ The Python SDK filtered turn notifications by comparing each payload directly wi **Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree. +**Expose and index every subagent run id on the JSON-RPC wire.** Exact run identity is useful when a client must correlate two concurrent outcomes for the same child, but session-tree routing already has the authoritative start edge and each terminal notification's immutable parent. Expanding the protocol is unnecessary for this ownership decision. + ## Consequences -High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, and ancestry reuse across subscriptions. +High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one current parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, ancestry reuse across subscriptions, and reused child ids whose older runs settle out of order. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md index 397669cc13..214a5ef924 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md @@ -10,7 +10,7 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 ## 决策 -`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 和 `subagent.finished` 所包含的 child-to-parent(子到父)关系。会话订阅会沿客户端生命周期内保存的祖先关系图回溯每个 payload 中的 session ID、parent ID 与 child ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 +`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 所包含的 child-to-parent(子到父)关系。后续的 `subagent.finished` 会依据自身不可变的 parent ID 路由,但不会改写当前祖先关系,因此旧 run 即使在其 child ID 已被复用后才结束,也无法覆盖替代它的新会话。其他会话通知会沿客户端生命周期内保存的祖先关系图回溯自身 session ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 `Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。 @@ -22,6 +22,8 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 **只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。 +**在 JSON-RPC 线路上公开并索引每个 subagent run ID。** 当客户端必须关联同一 child 的两个并发结果时,精确 run 身份很有价值;但会话树路由已经拥有权威 start 关系和每条终止通知中不可变的 parent。没有必要为这一归属决策扩展协议。 + ## 后果 -高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积,以及跨订阅复用祖先关系。 +高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条当前父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积、跨订阅复用祖先关系,以及旧 run 乱序结束的复用 child ID。 diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index b80d83b5f4..8d4ec7f848 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -445,7 +445,7 @@ class HarnessClient: self._notification_subscribers.pop(subscription_id, None) def _record_session_relationship_locked(self, notification: Notification) -> None: - if notification.method not in {"subagent.started", "subagent.finished"}: + if notification.method != "subagent.started": return parent_id = notification.payload.get("parentSessionId") child_id = notification.payload.get("childSessionId") @@ -461,15 +461,18 @@ class HarnessClient: def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter: def belongs(notification: Notification) -> bool: payload = notification.payload - related_ids = ( - payload.get("sessionId"), - payload.get("parentSessionId"), - payload.get("childSessionId"), - ) - return any( + if notification.method in {"subagent.started", "subagent.finished"}: + parent_id = payload.get("parentSessionId") + if ( + isinstance(parent_id, str) + and self._session_is_descendant_of(parent_id, session_id) + ): + return True + return payload.get("childSessionId") == session_id + related_id = payload.get("sessionId") + return ( isinstance(related_id, str) and self._session_is_descendant_of(related_id, session_id) - for related_id in related_ids ) return belongs diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index fda0686980..d5460b8683 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: @@ -442,6 +442,65 @@ def test_session_subscription_keeps_descendant_relationships_across_subscription assert client._notifications.qsize() == 0 +def test_session_subscription_preserves_reused_child_ancestry_after_late_finish() -> None: + client = HarnessClient() + old_seen: list[Notification] = [] + new_seen: list[Notification] = [] + with ( + client.subscribe_session_notifications("old-parent") as old_subscription, + client.subscribe_session_notifications("new-parent") as new_subscription, + ): + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in old_seen] == ["subagent.started"] + assert new_seen == [] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "new-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in new_seen] == ["subagent.started"] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.finished", + "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in old_seen] == [ + "subagent.started", + "subagent.finished", + ] + assert [notification.method for notification in new_seen] == ["subagent.started"] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "reused-child", "event": {"type": "assistant/message"}}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + + assert [notification.method for notification in old_seen] == [ + "subagent.started", + "subagent.finished", + ] + assert [notification.method for notification in new_seen] == [ + "subagent.started", + "session.event", + ] + assert client._notifications.qsize() == 0 + + def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: script = tmp_path / "fake_bridge.py" script.write_text( From 086e4549310436199c39ce9f860579a5f70416eb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:27:20 +0800 Subject: [PATCH 061/113] refactor(agent): name delivery methods by intent --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 17 +- ...ied-send-and-coalesced-user-messages.zh.md | 17 +- ...7-24-intent-named-agent-delivery.i18n.yaml | 6 + .../2026-07-24-intent-named-agent-delivery.md | 50 +++++ ...26-07-24-intent-named-agent-delivery.zh.md | 50 +++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 32 +-- docs/architecture.zh.md | 28 +-- docs/cordis-catalog/events.md | 46 ++--- docs/core-data-structures/core.md | 194 +++++++----------- docs/event-producer-consumer.md | 36 ++-- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../time-context/tests/time-context.spec.ts | 2 +- .../tests/workspace-context.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 36 +--- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/src/agent.ts | 99 +++++++-- packages/core/agent-loop/src/inbox.ts | 10 +- packages/core/agent-loop/tests/cancel.spec.ts | 8 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- packages/core/agent/README.md | 10 +- packages/core/agent/src/types.ts | 194 +++++++----------- packages/core/agent/tests/agent.spec.ts | 27 ++- .../command-goal/tests/command-goal.spec.ts | 6 +- .../goal-session/tests/goal-session.spec.ts | 8 +- packages/goal/goal/tests/goal.spec.ts | 8 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 6 +- packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- packages/tasks/tasks/tests/tasks.spec.ts | 2 +- packages/ui/tui/tests/harness.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 12 +- scripts/gen-cordis-api.ts | 51 +---- scripts/type-equiv.manifest.json | 7 +- 39 files changed, 514 insertions(+), 484 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md create mode 100644 .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 7c3ed96243..c5c9c83dc8 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e +2026-07-22-unified-send-and-coalesced-user-messages.md: 9f6b29b4e1d90d75dbb88ab770c7393cdb859627 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4469d211da23a24f61017770b29bc983a2abb5f1 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9eb355128b..9f6b29b4e1 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -1,4 +1,4 @@ -# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message +# Agent Note: Unify agent delivery and coalesce injected context into user/message Status: implemented @@ -12,17 +12,17 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller. +**One private mechanism, four public intents.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one private (`target` × `wakeup`) delivery mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes no routing fields or abstract base; the [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. -**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. +**inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. **context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. **Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. -**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. +**Delivery returns an id.** Each delivery method returns an opaque branded `AgentMessageId` for the accepted input. FIFO methods carry it through their inbox lifecycle events; injection bypasses those events. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, `target`/`wakeup`, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, steering/wakeup facts, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. **cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). @@ -30,16 +30,17 @@ Separately, `context/message` and `user/message` had converged: the surface proj - **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead. - **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. -- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the accepted routing facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. ## Consequences -The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +The concrete driver has one delivery mechanism, while the public structural interface names four intents and hides its (`target` × `wakeup`) matrix. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog. +Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it, both at the in-turn stop point and on the post-turn drain of late steering, and a loop-authored continuation reason is snapshotted and frozen like public steering. ## Related - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public interface and private routing placement. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 679c9100aa..4469d211da 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message +# Agent Note: 统一 agent 投递并把注入的上下文合并进 user/message Status: implemented @@ -12,17 +12,17 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一个原语,三个预设别名。** `Agent` 现在是一个抽象类,其唯一的抽象方法 `send(content, { target, wakeup, source, contexts, meta })` 覆盖 (`target` × `wakeup`) 矩阵。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)是基类上的具体委托方法,因此具体驱动器只需实现一次 `send`,就能继承这些好用的预设。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`send` 默认使用 `{ target: 'next-turn', wakeup: true }`,因此此前每一次裸调用 `agent.send(content)` 都保持完全相同的行为。`next-turn`/no-wakeup(入队但不唤醒)现在可以表达,只是没有别名,也没有当前调用方。 +**一个私有机制,四种公开意图。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个私有的(`target` × `wakeup`)投递机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口不暴露路由字段或抽象基类;取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 -**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:在当前日志位置追加的持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时的一次性 `injection` 轮次。它完全绕过 FIFO 队列,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 +**inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 **context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 **goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 -**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 +**投递返回一个 id。** 每种投递方法都为被接受的输入返回一个不透明的 branded `AgentMessageId`。FIFO 方法通过其 inbox 生命周期事件携带这个 id;注入绕过这些事件。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、`target`/`wakeup`、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、steering/wakeup 事实、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 **cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 @@ -30,16 +30,17 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` - **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 - **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 -- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了 `target`/`wakeup` 的事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了已接受的路由事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 ## 后果 -投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +具体驱动器只有一个投递机制,而公开的结构化接口以四种意图为方法命名,并隐藏其(`target` × `wakeup`)矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。 +在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。 ## 相关 - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开接口和私有路由的归属位置。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml new file mode 100644 index 0000000000..45b232613e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-intent-named-agent-delivery.md: df58435e5a59dbc5e3c2ba17c624728533ed3b96 +2026-07-24-intent-named-agent-delivery.zh.md: 78cf25870d3f220373692caf943870b920fb151e diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md new file mode 100644 index 0000000000..df58435e5a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -0,0 +1,50 @@ +# Agent Note: Name public agent delivery by intent + +Status: implemented + +English | [中文](2026-07-24-intent-named-agent-delivery.zh.md) + +## Problem + +A public `send(content, { target, wakeup, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. The mechanism is useful inside the concrete driver, but exposing it gives plugins implementation knowledge without leverage. + +Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter. + +## Decision + +`Agent` is a structural interface with four intent-named delivery methods: + +- `send()` queues an ordinary turn and wakes the driver. +- `queue()` queues an ordinary turn without waking an idle driver. +- `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn. +- `inject()` appends model-facing context without running the model. + +`send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. + +`ReactLoopAgent` resolves each public call into one module-private `ResolvedDelivery` and passes it to native-private `#acceptDelivery`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. + +The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop`. It is not exported, protected, or represented by a base class. With one concrete adapter, a subclass delivery seam would be hypothetical; callers and tests use the same public `Agent` interface. + +## Alternatives considered + +**Keep a public configurable primitive.** Mandatory routing arguments remove defaulting mistakes but still require every caller to learn the matrix and allow invalid intent combinations such as attached contexts on injection. The concrete loop needs that flexibility; ordinary plugins do not. + +**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** Visibility belongs in the type and runtime boundary, not a warning in a public name. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work. + +**Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias. + +**Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content. + +## Verification + +Focused agent-loop coverage exercises waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes and rejects routing fields on `SendOptions` and contexts on `InjectOptions`. The keyless Cordis inspection snapshot pins the model-facing interface without a configurable delivery primitive or abstract-class implementation. + +## Consequences + +Callers choose one verb instead of encoding two routing axes. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface restores simple adapters and fakes. Adding a delivery intent now requires an explicit public name and mapping rather than another matrix combination. + +Four short public methods duplicate a small amount of argument resolution inside the concrete adapter. That duplication is deliberate locality: defaults and routing stay beside the only implementation that owns them, and no generator support is needed merely to expose a class-shaped `Agent`. + +## Related + +- [unified delivery and coalesced user messages](2026-07-22-unified-send-and-coalesced-user-messages.md) owns the shared acceptance mechanism, inbox lifecycle, and durable event convergence this decision narrows at the public seam. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md new file mode 100644 index 0000000000..78cf25870d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 按意图命名公开的 agent 投递 + +Status: implemented + +[English](2026-07-24-intent-named-agent-delivery.md) | 中文 + +## 问题 + +公开的 `send(content, { target, wakeup, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。该机制在具体驱动器内部很有用,但将其公开只会让插件背负实现知识,却得不到相应收益。 + +通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。 + +## 决策 + +`Agent` 是一个结构化接口,提供四种按意图命名的投递方法: + +- `send()` 将一个普通轮次入队并唤醒驱动器。 +- `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 +- `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。 +- `inject()` 追加面向模型的上下文,但不运行模型。 + +`send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 + +`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedDelivery`,再将其传给原生私有的 `#acceptDelivery`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 + +target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出,不是 protected 成员,也不由基类表示。只有一个具体适配器时,子类投递 seam 只是假想的;调用方和测试使用同一个公开 `Agent` 接口。 + +## 考虑过的替代方案 + +**保留公开的可配置原语。** 强制提供路由参数可以消除默认值错误,但仍会要求每个调用方理解矩阵,也允许出现无效的意图组合,例如为注入附加上下文。具体循环需要这种灵活性;普通插件不需要。 + +**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 可见性应由类型和运行时边界表达,而不是在公开名称中加入警告。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作。 + +**保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名。 + +**先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。 + +## 验证 + +聚焦的 agent-loop 覆盖率测试通过公开方法覆盖唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,并拒绝 `SendOptions` 上的路由字段和 `InjectOptions` 上的上下文。无密钥的 Cordis 检查快照固定了面向模型的接口,其中既没有可配置的投递原语,也没有抽象类实现。 + +## 后果 + +调用方选择一个动词即可,无需编码两条路由轴。具体循环保留一条接受路径和一个归属边界,而结构化接口重新支持简单的适配器和测试替身。新增一种投递意图时,需要显式给出公开名称和映射,而不是再增加一种矩阵组合。 + +四个简短的公开方法会在具体适配器中重复少量参数解析。这项重复是为了让实现保持局部:默认值和路由都留在拥有它们的唯一实现旁边,无需仅为了暴露类形态的 `Agent` 而增加生成器支持。 + +## 相关 + +- [统一投递并合并 user 消息](2026-07-22-unified-send-and-coalesced-user-messages.md)负责定义共享的接受机制、inbox 生命周期和持久事件趋同;本决策只收窄它们的公开 seam。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..7463516dba 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 -architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee +architecture.md: edb42170fb508e522f2f26fd576902cc284e9eb4 +architecture.zh.md: e60fc2b2c61f139bbca59a3e9daa4703b2ce5976 diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..edb42170fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ English | [中文](architecture.zh.md) ## Overview -Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute services, typed events, and disposable registrations. +Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed services, typed events, and disposable registrations. `packages/core/` groups the default agent flow; capabilities remain plugins. @@ -49,7 +49,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv ## Event -Events form the service extension API; see the exhaustive [events catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). +Events form the service extension API; see the [catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). ### Event Domains @@ -63,11 +63,11 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop runs prompt-to-checkpoint work through plugin services and events. +The loop runs through plugin services and events. -A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. +A **session** is append-only. Each ordinary **turn** claims one queued message; injection claims none. Successors await the preceding checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A **step** is one model request plus tools; quotes in the [sequence below](agent-lifecycle.md) mark durable events. -Without an id, creation mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. +Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. ### Turn Flow @@ -115,37 +115,37 @@ forever: checkpoint persistence and notify idle/running status ``` -Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Steps assemble ordered prompt sections, tool schemas, and variables; unknown references fail turns. `dsh-system-prompt` owns identity and persona; the loop supplies `model` and `cwd` ([ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `inject()` and post-tool `additionalContexts`—settles after results. Steering drains before `agent/post-step`, which sees durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush; later steering is discarded while queued prompts remain. +Async `inject()` and post-tool `additionalContexts` settle after results; steering drains before `agent/post-step`. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush and discards later steering, not queued prompts. -Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Bounded retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). ### Failure Boundaries -Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. +Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. +Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber, factory provider, and consumer handle co-own teardown through one awaited disposer. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent-named `send()`, `queue()`, `steer()`, and `inject()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)); `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. ### Agent Scope -Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, prompt, and command entries while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch, and every scoped contribution unwinds with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns a scoped `agent.ctx` over global tool, prompt, and command storage ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)); scoped listeners filter and contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication; typed resolvers derive carrier checks from `Events` and `scopeTarget` ([gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State ### Session Log -The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events remain for replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from the same stream. +The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence share that stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: `step/start` messages plus the header's session prefix and folded `request/header` reconstruct every request; `dsh-agent-loop/invariant` asserts this through `ctx.invariants` ([decision](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern; backends buffer synchronous `session/event` notifications. Checkpoints drain before adapter dispatch, recorded top-level tool calls before tool dispatch, complete response/result batches at `agent/post-step`, and final turn ends. `SessionPersistence` stores `SessionEvent` plus `SessionHeader` metadata; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). `ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..e60fc2b2c6 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -49,7 +49,7 @@ ## 事件 -事件构成服务的扩展 API;完整清单见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 +事件构成服务的扩展 API;参见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 ### 事件域 @@ -63,9 +63,9 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` ## 默认循环生命周期 -已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。 +循环通过插件服务和事件运行。 -**会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 +**会话**采用仅追加方式。每个普通**轮次**领取一条已排队的消息;注入不领取消息。后续轮次会等待前一个检查点,但可以与前一轮次共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。一个**步骤**包含一次模型请求及其工具;在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 未提供 id 时,创建流程会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 @@ -115,37 +115,37 @@ forever: checkpoint persistence and notify idle/running status ``` -每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 +各步骤会组装有序提示词片段、工具 schema 和变量;未知引用会使轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `model` 和 `cwd`([归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -工具执行阶段的上下文,包括异步 `inject()` 和工具执行后的 `additionalContexts`,会在结果产生后稳定。steering(中途引导)会在 `agent/post-step` 前排空;该事件会观察持久输出、结果、上下文和 steering。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权;后续 steering 会被丢弃,排队提示词仍予保留。 +异步 `inject()` 和工具执行后的 `additionalContexts` 会在结果产生后稳定;steering(中途引导)会在 `agent/post-step` 前排空。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权,会丢弃后续 steering,而不丢弃排队提示词。 -裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。有界重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 ### 失败边界 -适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启另一个步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交消息或工具。 +适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。 -其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。 +会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`steer()`、`inject()`、`cancel()` 和 `whenIdle()`。调用方 fiber、工厂提供方和消费方句柄通过同一个需等待完成的 disposer 共同拥有拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md));`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 ### Agent 作用域 -每个 agent 都拥有一个作用域化的 `agent.ctx`;共享存储会在全局工具、提示词和命令条目之上叠加作用域条目,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派,每项作用域贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 +每个 agent 都拥有一个作用于全局工具、提示词和命令存储的作用域化 `agent.ctx`([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md));作用域监听器会过滤分派,各项贡献会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合;类型化解析器从 `Events` 和 `scopeTarget` 推导载体检查([门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 ## 状态 ### 会话日志 -会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件留在日志中,以保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自同一个事件流。 +会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保留回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化共用该事件流。 -**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:`step/start` 消息、请求头中的会话前缀和折叠后的 `request/header` 共同重建每个请求;`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言这一点([决策](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责;后端会缓冲同步的 `session/event` 通知。检查点会在适配器分发前排空,在工具分发前刷写已记录的顶层工具调用,在 `agent/post-step` 刷写完整的响应与结果批次,并刷写最终的轮次结束。`SessionPersistence` 存储 `SessionEvent` 和 `SessionHeader` 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 `ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 56c927e0db..fa8494b14c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -138,11 +138,11 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** @@ -150,7 +150,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection - * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * through `agent.inject()` bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -161,7 +161,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:325`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:413`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:344`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,16 +333,16 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. +Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking delivery does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does - * not enter `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking + * delivery does not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -353,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +376,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:401`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 37e6ddc6dc..85689a89d6 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -361,35 +361,11 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — the item joins the active turn between steps as steering, - * or, when no turn is active, is promoted per its `wakeup` flag. - */ -type SendTarget = 'next-turn' | 'next-step' -``` - -```ts type-equiv -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * + * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ interface SendOptions { - /** Queue the item joins; defaults to `next-turn`. */ - target?: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). Defaults to - * `true`. A `false` `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -402,19 +378,22 @@ interface SendOptions { } ``` -The fixed-preset aliases own `target` and `wakeup`, so they accept only the remaining fields: - ```ts type-equiv -/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ -type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'> +/** Options specific to durable synthetic context injection. */ +interface InjectOptions { + /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ + source?: MessageSource + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue +} ``` -`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events: +FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events: ```ts type-equiv /** - * Opaque id assigned to one accepted {@link Agent.send} message; returned by - * `send` and carried on its `agent/inbox/*` events for correlation. + * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id + * on their `agent/inbox/*` events; injection bypasses those events. */ type AgentMessageId = Branded<'AgentMessageId'> ``` @@ -423,24 +402,24 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** - * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live - * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Source defaults are already - * applied, so these are the exact values the item was accepted with. `steering` - * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is - * durable model-hidden state that lands on the eventual `user/message`/ + * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` + * is the value `send`, `queue`, or `steer` returned to the caller, stable across + * this message's enqueue, dequeue, and discard events. Source defaults are + * already applied, so these are the exact values the item was accepted with. + * `steering` is true for an item drained between steps; otherwise it is claimed + * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable + * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ interface AgentMessage { - /** The id `send` returned for this message. */ + /** The id returned by the accepting `send`, `queue`, or `steer` call. */ id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + /** Whether the item joined the steering FIFO rather than the queued FIFO. */ steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ + /** Whether the item wakes the driver or requests another step. */ wakeup: boolean } ``` @@ -464,49 +443,69 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -`Agent` is an abstract class: concrete drivers implement the abstract members, while `followup`/`steer`/`inject` are shared concrete delegates to the single abstract `send` over the (`target` × `wakeup`) matrix. +The structural `Agent` interface exposes four delivery intents. The concrete driver resolves them into a private routing mechanism rather than exporting the target/wakeup matrix. ```ts type-equiv -/** - * Public agent handle; its concrete implementation is internal to - * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so - * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, - * {@link Agent.inject}) are shared concrete delegates over the single abstract - * {@link Agent.send} primitive; concrete drivers implement `send` once. - */ -abstract class Agent { +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId + readonly id: SessionId /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions + readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session + readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * Detaches, validates, and freezes one lossless-JSON item, then routes it: - * - * - `next-turn` (default) queues an item that becomes the sole ordinary - * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` submits steering into the active turn - * (idle falls back to a woken `next-turn`). - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: an open turn joins at the current log position - * (deferred behind an executing tool batch until it settles), and an idle - * inject records a one-shot turn with its own durability checkpoint. - * - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before any notification, enqueue, or append. - * @param content - the model-facing content blocks to deliver. - * @param options - target queue, wakeup decision, source, contexts, and meta. + * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. + * Content, resolved source, and attached contexts are detached, validated, + * and frozen together; invalid input throws synchronously before notification + * or enqueue. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId + send(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Queue an ordinary message without waking an idle driver. The item retains + * FIFO order and is claimed only after another input wakes the driver. A lone + * queued item leaves `whenIdle()` resolved. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Submit steering into the running turn and request another step. An open turn + * records it at the next steering checkpoint before a request or continuation + * decision; policy may stop before another step. After turn close and its + * checkpoint, any remainder is queued for a later turn; terminal + * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering + * becomes a waking ordinary turn. + * @param content - the steering content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before + * turn close even when interrupted. Idle injection uses a one-shot turn and + * durability checkpoint. Disposal awaits idle checkpoints; flush failures + * report through `agent/error`. An omitted source defaults to + * `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. + */ + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -518,53 +517,10 @@ abstract class Agent { * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void + cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise<void> - - /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. - * @param content - the prompt content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-turn', wakeup: true }) - } - - /** - * Submit steering into the running turn — the `next-step`/wakeup preset of - * {@link send}. An open turn records it at the next steering checkpoint before - * a request or continuation decision; policy may stop before another step. - * After turn close and its checkpoint, any remainder is queued for a later - * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. - * Idle steering falls back to a woken follow-up turn. - * @param content - the steering content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: true }) - } - - /** - * Append detached model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins - * at the current log position unless the current tool batch is executing; - * then it waits FIFO until that batch settles and drains before turn close - * even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through - * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}. - */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: false }) - } + whenIdle(): Promise<void> } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f0469118ba..5337683ccd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:413`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:344`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:428`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:401`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 075138effc..e9d139c8be 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index af219d02db..1121d8e9cd 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'>;\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ae1b4c5a0b..0f196a590a 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -43,7 +43,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { status: 'running', ctx: new Context(), send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 161466a8b8..6b7da3b19c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -178,7 +178,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session, status: 'idle', send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { session.append('user/message', { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 150c4880b3..446ded7d0e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * through `agent.inject()` bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { @@ -955,7 +955,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/status', mode: 'emit', signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does\n * not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking\n * delivery does not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).', }, { @@ -1181,7 +1181,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n}', }, { name: 'AgentCancelCause', @@ -1207,10 +1207,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', }, - { - name: 'AliasSendOptions', - declaration: 'export type AliasSendOptions = Omit<SendOptions, \'target\' | \'wakeup\'>;', - }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -1523,6 +1519,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', }, + { + name: 'InjectOptions', + declaration: 'export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n}', + }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', @@ -1547,10 +1547,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, - { - name: 'LlmAdapter', - declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}', - }, { name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', @@ -1753,15 +1749,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SendOptions', - declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', - }, - { - name: 'SendTarget', - declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';', - }, - { - name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', }, { name: 'SessionAvailability', @@ -1895,10 +1883,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchRequest', declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, - { - name: 'SessionSurface', - declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', - }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -2031,10 +2015,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, - { - name: 'SurfaceIntent', - declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', - }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d2d18b61b2..68677876f8 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -50,9 +50,9 @@ Configured agents start automatically. A model call requires both `provider` and ### Internal concrete driver -The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. +The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO (waking the driver unless `wakeup: false`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptDelivery`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index baa2e3f08d..8dc6e95808 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -9,11 +9,19 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' -import { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, SendOptions } from '@deepseek-ai/dsh-agent' +import type { + Agent, + AgentCancelCause, + AgentOptions, + AgentStatus, + CancelOptions, + HookContext, + InjectOptions, + SendOptions, +} from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type JsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -33,6 +41,17 @@ const bindContext = Symbol('dsh.agent-loop.bind-context') /** Module-private publication marker. */ const publishAgent = Symbol('dsh.agent-loop.publish-agent') +/** Fully resolved input accepted only by the concrete driver's private delivery mechanism. */ +type ResolvedDelivery = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) + /** Factory-owned controls that can operate only on the agent created with them. */ export interface PreparedReactLoopAgent { /** The unpublished concrete agent. */ @@ -101,7 +120,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): * the loop driver. Everything observable happens through session events and * the agent/* event taxonomy — plugins never need this class. */ -export class ReactLoopAgent extends Agent { +export class ReactLoopAgent implements Agent { /** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */ readonly #inbox = new Inbox() @@ -162,7 +181,6 @@ export class ReactLoopAgent extends Agent { public readonly session: Session, maxParallelToolCalls: number, ) { - super() this.maxParallelToolCalls = maxParallelToolCalls const { promise, resolve } = Promise.withResolvers<void>() this.disposed = promise @@ -197,13 +215,11 @@ export class ReactLoopAgent extends Agent { * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private acceptMessage( - id: AgentMessageId, content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions, - ): InboxMessage { - const contexts = options?.contexts ?? [] + private snapshotMessage(id: AgentMessageId, delivery: ResolvedDelivery): InboxMessage { + const { content, source, contexts, wakeup, meta } = delivery const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup, - ...options?.meta !== undefined ? { meta: options.meta } : {}, + ...meta !== undefined ? { meta } : {}, }) if (accepted === undefined) { throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable') @@ -225,18 +241,17 @@ export class ReactLoopAgent extends Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - send(content: ContentBlock[], options?: SendOptions): AgentMessageId { + /** Accept one fully resolved intent through the concrete driver's private routing matrix. */ + #acceptDelivery(delivery: ResolvedDelivery): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) - const target = options?.target ?? 'next-turn' - const wakeup = options?.wakeup ?? true + const { target, wakeup } = delivery // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return id } + if (target === 'next-step' && !wakeup) { this.injectContext(delivery); return id } // next-step/wakeup is steering into the running turn; idle falls back to a - // woken follow-up turn (there is no active turn to attach to). + // waking ordinary turn (there is no active turn to attach to). const steering = target === 'next-step' && this._status === 'running' - const source = options?.source ?? { kind: 'user' } - const accepted = this.acceptMessage(id, content, source, wakeup, options) + const accepted = this.snapshotMessage(id, delivery) if (steering) { this.#inbox.steer(accepted) } else { @@ -246,13 +261,57 @@ export class ReactLoopAgent extends Agent { return id } + send(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-turn', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-turn', + wakeup: false, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-step', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + contexts: options?.contexts ?? [], + meta: options?.meta, + }) + } + + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { + return this.#acceptDelivery({ + content, + target: 'next-step', + wakeup: false, + source: options?.source ?? { kind: 'plugin', plugin: '' }, + contexts: [], + meta: options?.meta, + }) + } + /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ - private injectContext(content: ContentBlock[], options?: SendOptions): void { - const source = options?.source ?? { kind: 'plugin', plugin: '' } + private injectContext(delivery: Extract<ResolvedDelivery, { target: 'next-step'; wakeup: false }>): void { + const { content, source, meta } = delivery const context = { content, source, - ...options?.meta !== undefined ? { meta: options.meta } : {}, + ...meta !== undefined ? { meta } : {}, } if (isTurnOpen(this.session)) { const accepted = this.acceptContext(context) diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index 3cb82944e4..8641d20321 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -1,7 +1,7 @@ /** * Per-agent message inbox: queued and steering FIFOs. Purely an in-memory - * mechanism of the loop driver — the public surface is `Agent.send()` and its - * fixed-preset aliases. + * mechanism of the loop driver — callers use `Agent`'s intent-named delivery + * methods instead. * * @module dsh-agent-loop/inbox */ @@ -10,7 +10,7 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent' -/** One message waiting in an agent's inbox; `id` is the value `send` returned. */ +/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */ export interface InboxMessage { id: AgentMessageId content: ContentBlock[] @@ -35,7 +35,7 @@ export function agentMessage(message: InboxMessage, steering: boolean): AgentMes /** * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of - * the loop — the public surface is `Agent.send()` and its aliases. + * the loop — the public surface is `Agent`'s intent-named delivery methods. */ export class Inbox { private queuedMessages: InboxMessage[] = [] @@ -78,7 +78,7 @@ export class Inbox { /** * Add a message to the steering FIFO. Deliberately no wakeup: steering is * drained between steps of a running turn, never by the idle wait — - * `Agent.steer()` on an idle agent falls back to a woken follow-up instead. + * `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead. * @param message - the message to inject between steps of the running turn. */ steer(message: InboxMessage): void { diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 3a7af2871f..62b75df154 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -106,7 +106,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) }) // Queue a turn WITHOUT waking the driver, so it sits in the inbox. - agent.send([{ type: 'text', text: 'preserved' }], { target: 'next-turn', wakeup: false }) + agent.queue([{ type: 'text', text: 'preserved' }]) // keepInbox cancel: no active turn, work preserved, no discard event. agent.cancel({ kind: 'user' }, { keepInbox: true }) expect(discards).toEqual([]) @@ -117,14 +117,14 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['preserved', 'wake it']) }) - it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => { + it('a lone queued message leaves the agent parked at idle', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A quiet item alone must NOT wake the driver: no turn runs and whenIdle // resolves (the agent is quiescent), leaving the item queued. - agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + agent.queue([{ type: 'text', text: 'quiet' }]) await agent.whenIdle() expect(agent.status).toBe('idle') expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) @@ -140,7 +140,7 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'quiet' }], { target: 'next-turn', wakeup: false }) + agent.queue([{ type: 'text', text: 'quiet' }]) const idle = agent.whenIdle() // Cancel reaches quiescence with no status transition and no waking send; // whenIdle must still resolve (previously it hung until the next send). diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8d3016d872..8b8a4c8c92 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -539,7 +539,7 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }], { target: 'next-turn', wakeup: true, meta: { prompt: 1 } }) + agent.send([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } }) await waitForIdle(ctx, agent) const user = agent.session.events.find(e => e.type === 'user/message') diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ebb756b139..647cae2b22 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,12 +54,12 @@ Turn and step boundaries and the model token stream are durable `session/event` ### Agent interface (`types.ts`) -The handle every plugin programs against: +`Agent` is a structural interface. Public delivery methods name caller intent; the concrete driver keeps queue targeting and wakeup routing private ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `send()`, `queue()`, and `steer()` return an opaque `AgentMessageId` carried by that FIFO item's `agent/inbox/enqueue`/`dequeue`/`discard` events. Each snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. Omitting `options.source` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. -- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. -- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. -- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. +- `agent.send(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message. +- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. - `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d542c39810..b120fb6689 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -27,33 +27,11 @@ export interface AgentOptions { } /** - * Which inbox queue a {@link Agent.send} item joins: - * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. - * - `next-step` — the item joins the active turn between steps as steering, - * or, when no turn is active, is promoted per its `wakeup` flag. - */ -export type SendTarget = 'next-turn' | 'next-step' - -/** - * Options for the unified {@link Agent.send} primitive over the - * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} - * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and - * {@link Agent.inject} (`next-step`/no-wakeup). - * + * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ export interface SendOptions { - /** Queue the item joins; defaults to `next-turn`. */ - target?: SendTarget - /** - * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). Defaults to - * `true`. A `false` `next-turn` item queues without waking; a `false` - * `next-step` item attaches durable context without forcing another step - * (the injection preset). - */ - wakeup?: boolean source?: MessageSource /** * Model-facing contexts captured with this inbox item. A queued prompt exposes @@ -65,12 +43,17 @@ export interface SendOptions { meta?: JsonValue } -/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */ -export type AliasSendOptions = Omit<SendOptions, 'target' | 'wakeup'> +/** Options specific to durable synthetic context injection. */ +export interface InjectOptions { + /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ + source?: MessageSource + /** Opaque JSON state retained on the durable message but hidden from the model. */ + meta?: JsonValue +} /** - * Opaque id assigned to one accepted {@link Agent.send} message; returned by - * `send` and carried on its `agent/inbox/*` events for correlation. + * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id + * on their `agent/inbox/*` events; injection bypasses those events. */ export type AgentMessageId = Branded<'AgentMessageId'> @@ -84,24 +67,24 @@ export function AgentMessageId(id: string): AgentMessageId { } /** - * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live - * events. `id` is the value `send` returned to the caller, stable across this - * message's enqueue, dequeue, and discard events. Source defaults are already - * applied, so these are the exact values the item was accepted with. `steering` - * is true for a `next-step` item drained between steps; a `next-turn` item is - * claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is - * durable model-hidden state that lands on the eventual `user/message`/ + * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` + * is the value `send`, `queue`, or `steer` returned to the caller, stable across + * this message's enqueue, dequeue, and discard events. Source defaults are + * already applied, so these are the exact values the item was accepted with. + * `steering` is true for an item drained between steps; otherwise it is claimed + * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable + * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ export interface AgentMessage { - /** The id `send` returned for this message. */ + /** The id returned by the accepting `send`, `queue`, or `steer` call. */ id: AgentMessageId content: ContentBlock[] source: MessageSource contexts: HookContext[] - /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */ + /** Whether the item joined the steering FIFO rather than the queued FIFO. */ steering: boolean - /** Whether the item is marked to wake the driver or force a continuation. */ + /** Whether the item wakes the driver or requests another step. */ wakeup: boolean } @@ -119,7 +102,7 @@ export interface CancelOptions { * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (the driver is draining * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`followup`/`steer`/`inject` throw). + * transition leaves it, and `send`/`queue`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -179,46 +162,66 @@ export type AgentCancelCause = /** Runtime reason carried by the signal that controls one live turn. */ export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } -/** - * Public agent handle; its concrete implementation is internal to - * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so - * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer}, - * {@link Agent.inject}) are shared concrete delegates over the single abstract - * {@link Agent.send} primitive; concrete drivers implement `send` once. - */ -export abstract class Agent { +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +export interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId + readonly id: SessionId /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions + readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session + readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** - * The unified delivery primitive over the (`target` × `wakeup`) matrix. - * Detaches, validates, and freezes one lossless-JSON item, then routes it: - * - * - `next-turn` (default) queues an item that becomes the sole ordinary - * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a - * parked driver, while `wakeup:false` queues without waking. - * - `next-step` with `wakeup:true` submits steering into the active turn - * (idle falls back to a woken `next-turn`). - * - `next-step` with `wakeup:false` injects durable model-facing context - * without running the model: an open turn joins at the current log position - * (deferred behind an executing tool batch until it settles), and an idle - * inject records a one-shot turn with its own durability checkpoint. - * - * Attached contexts share the same snapshot and ownership boundary. Invalid - * input throws synchronously before any notification, enqueue, or append. - * @param content - the model-facing content blocks to deliver. - * @param options - target queue, wakeup decision, source, contexts, and meta. + * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. + * Content, resolved source, and attached contexts are detached, validated, + * and frozen together; invalid input throws synchronously before notification + * or enqueue. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId + send(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Queue an ordinary message without waking an idle driver. The item retains + * FIFO order and is claimed only after another input wakes the driver. A lone + * queued item leaves `whenIdle()` resolved. + * @param content - the prompt content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + queue(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Submit steering into the running turn and request another step. An open turn + * records it at the next steering checkpoint before a request or continuation + * decision; policy may stop before another step. After turn close and its + * checkpoint, any remainder is queued for a later turn; terminal + * `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering + * becomes a waking ordinary turn. + * @param content - the steering content blocks. + * @param options - source, attached contexts, and durable model-hidden meta. + * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. + */ + steer(content: ContentBlock[], options?: SendOptions): AgentMessageId + + /** + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before + * turn close even when interrupted. Idle injection uses a one-shot turn and + * durability checkpoint. Disposal awaits idle checkpoints; flush failures + * report through `agent/error`. An omitted source defaults to + * `{ kind: 'plugin', plugin: '' }`. + * @param content - the injected context content blocks. + * @param options - source and durable model-hidden meta. + * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. + */ + inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -230,53 +233,10 @@ export abstract class Agent { * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void + cancel(cause?: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise<void> - - /** - * Queue an ordinary follow-up turn and wake the driver — the - * `next-turn`/wakeup preset of {@link send}. The item becomes the sole - * ordinary message of its own turn. - * @param content - the prompt content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-turn', wakeup: true }) - } - - /** - * Submit steering into the running turn — the `next-step`/wakeup preset of - * {@link send}. An open turn records it at the next steering checkpoint before - * a request or continuation decision; policy may stop before another step. - * After turn close and its checkpoint, any remainder is queued for a later - * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it. - * Idle steering falls back to a woken follow-up turn. - * @param content - the steering content blocks. - * @param options - source and attached contexts. - * @returns the accepted message's {@link AgentMessageId}. - */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: true }) - } - - /** - * Append detached model-facing context without running the model — the - * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins - * at the current log position unless the current tool batch is executing; - * then it waits FIFO until that batch settles and drains before turn close - * even when interrupted. Idle injection uses a one-shot turn and durability - * checkpoint. Disposal awaits idle checkpoints; flush failures report through - * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}. - */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: false }) - } + whenIdle(): Promise<void> } declare module 'cordis' { @@ -303,8 +263,8 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped<Agent>, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does - * not enter `running` synchronously; drive lifecycle from this event. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking + * delivery does not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -316,7 +276,7 @@ declare module 'cordis' { * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection - * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this. + * through `agent.inject()` bypasses the FIFOs and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index aefa739dde..39a65fa996 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -3,32 +3,47 @@ import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { - Agent, AgentMessageId, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { + Agent, + AgentCancelCause, + AgentFactory, + ContinuationStop, + CreateAgentOptions, + InjectOptions, + ResumeAgentOptions, + SendOptions, +} from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent { const id = SessionId(rawId) - // Agent is an abstract class, so its alias methods live on the prototype and - // object spread would drop them; build the full literal and merge overrides. - return Object.assign(Object.create(Agent.prototype) as Agent, { + return { id, options: {}, session: new Session(id), status: 'idle', ctx: new Context(), send: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, - }) + } } describe('AgentRegistry', () => { + it('keeps concrete delivery routing out of public options', () => { + expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>() + expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>() + expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf<false>() + }) + it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => { type TurnStopListener = Events['agent/turn-stop'] type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined> diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 8964d02df2..2611d7f3e6 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -26,7 +26,7 @@ function nextTurn(session: Session): number { } /** Append one idle injection using the public Agent contract's balanced shape. */ -function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const turn = nextTurn(session) session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) @@ -49,7 +49,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } { ctx: new Context(), get status() { return status }, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, cancel() { status = 'idle' }, diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index d4994466f9..88f8ff202b 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -481,11 +481,11 @@ describe('same-session goal driving', () => { it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { const test = await harness([]) - // inject shares send, so reject only the round send (a goal-sourced - // next-turn item), not the goal state-change injection that precedes it. + // Reject only the goal-sourced round send, not the state-change injection + // that precedes it. const realSend = test.agent.send.bind(test.agent) vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { - if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + if (options?.source?.kind === 'goal') { throw new Error('queue rejected') } return realSend(content, options) @@ -506,7 +506,7 @@ describe('same-session goal driving', () => { const test = await harness([]) const realSend = test.agent.send.bind(test.agent) vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { - if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') { + if (options?.source?.kind === 'goal') { test.ctx.goals.disarm(test.agent) throw new Error('queue rejected after disarm') } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 4f6e4e2cfc..5f037d3c6b 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import GoalService, { @@ -15,7 +15,7 @@ import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek- interface DeferredInjection { content: ContentBlock[] - options: AliasSendOptions | undefined + options: InjectOptions | undefined } interface StubAgent { @@ -33,7 +33,7 @@ function nextTurn(session: Session): number { } /** Mirror the public Agent.inject idle/open-turn contract for domain tests. */ -function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void { +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' } const context = { content, @@ -65,7 +65,7 @@ function stubAgentForSession(session: Session): StubAgent { ctx: new Context(), get status() { return status }, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { if (shouldDefer) deferred.push({ content, options }) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index f5bc34924a..a419cfd976 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { CallId } from '@deepseek-ai/dsh-llm' @@ -32,9 +32,9 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { get status() { return status }, ctx: new Context(), send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), - inject(content: ContentBlock[], options?: AliasSendOptions) { + inject(content: ContentBlock[], options?: InjectOptions) { const source = options?.source ?? { kind: 'plugin', plugin: '' } session.append('user/message', { content, diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1112821841..c9aef7ce1f 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers<undefined>() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index f809393a7c..d0aa8db72e 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index a13861eb56..678403ef30 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -28,7 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle', ctx: scopeFiber.ctx, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index cf096ebb70..354ebce0ba 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index be32bfe2be..fce1d05bf7 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 3478c313c1..51c06904d0 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -24,7 +24,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { status: 'idle' as const, ctx: scopeFiber.ctx, send: () => AgentMessageId('stub'), - followup: () => AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 243f492d2c..36fd40475c 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -155,7 +155,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e sentOptions.push(options) return AgentMessageId('stub') }, - followup(content, options) { + queue(content, options) { sent.push(content) sentOptions.push(options) return AgentMessageId('stub') diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index f5b4763ef6..a855d99f81 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2499,7 +2499,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2523,7 +2523,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2557,14 +2557,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2594,7 +2594,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2636,7 +2636,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 9aedf69aca..7c61b9395d 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -30,49 +30,8 @@ function quote(value: string): string { } /** - * Reduce an exported class to its type shape: drop method/constructor bodies - * and property initializers so the catalog serves member signatures, not - * implementation. An abstract class (e.g. `Agent`) is a public type consumers - * program against, so it belongs in the type closure alongside interfaces. - */ -function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { - const isNonPublic = (member: ts.ClassElement): boolean => - (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => - m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false - const members = node.members.flatMap((member): ts.ClassElement[] => { - // A model-facing type shape carries only the public surface — drop private, - // protected, and #private members, and strip every kept member's body. - if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] - if (ts.isMethodDeclaration(member)) { - return [ts.factory.updateMethodDeclaration( - member, member.modifiers, member.asteriskToken, member.name, member.questionToken, - member.typeParameters, member.parameters, member.type, undefined)] - } - if (ts.isConstructorDeclaration(member)) { - return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] - } - if (ts.isGetAccessorDeclaration(member)) { - return [ts.factory.updateGetAccessorDeclaration( - member, member.modifiers, member.name, member.parameters, member.type, undefined)] - } - if (ts.isSetAccessorDeclaration(member)) { - return [ts.factory.updateSetAccessorDeclaration( - member, member.modifiers, member.name, member.parameters, undefined)] - } - if (ts.isPropertyDeclaration(member)) { - return [ts.factory.updatePropertyDeclaration( - member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] - } - return [member] - }) - return ts.factory.updateClassDeclaration( - node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) -} - -/** - * Collect exported interface, type-alias, and (body-stripped) class shapes; - * omit names declared in multiple packages rather than risk serving the wrong - * package's shape. + * Collect exported interface and type shapes; omit names declared in multiple + * packages rather than risk serving the wrong package's shape. */ function collectTypeDecls(scanRoot: string = root): Map<string, string> { const printer = ts.createPrinter({ removeComments: true }) @@ -82,16 +41,14 @@ function collectTypeDecls(scanRoot: string = root): Map<string, string> { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { - const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) - if (!named || stmt.name === undefined) continue + if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } - const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt - const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') + const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e57b170775..7aee2fc71b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -66,11 +66,6 @@ "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "SendTarget", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "SendOptions", @@ -78,7 +73,7 @@ }, { "doc": "docs/core-data-structures/core.md", - "symbol": "AliasSendOptions", + "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, { From 218c98c2c232eee415043cc62009ebca63d12195 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 12:12:18 +0800 Subject: [PATCH 062/113] fix(goal): tolerate strict provider filler fields --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 4 +- .../2026-07-19-model-facing-goal-tools.zh.md | 4 +- .../tool-schemas.expected.json | 8 +-- .../both-mode-turn/tool-schemas.expected.json | 8 +-- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../tests/snapshots/fs-edit/session.jsonl | 2 +- .../snapshots/fs-policy-reject/session.jsonl | 4 +- .../fs-write-overwrite/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- .../lsp-definition/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 16 ++--- .../tool-schemas.expected.json | 16 ++--- .../plan-mode/tool-schemas.expected.json | 16 ++--- .../pty-tools/tool-schemas.expected.json | 8 +-- .../skill-load/tool-schemas.expected.json | 8 +-- .../text-turn/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 8 +-- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/src/index.ts | 29 ++------- .../goal/tool-goal/tests/tool-goal.spec.ts | 61 +++++++++++-------- 22 files changed, 109 insertions(+), 119 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index e53c591aa5..b91a8758ca 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -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: 7df04678ab8f78be71504b9a0d6e3a05391833e3 +2026-07-19-model-facing-goal-tools.zh.md: 102337fd57291102ce16a3978bafe9fcf62b9695 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7cc3907d70..7df04678ab 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,7 +16,7 @@ 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`; each action consumes only its fields and ignores the others, while `blocked` still requires a non-empty `blocked_reason` and persists it under the stable `model-reported` code. This tolerance is necessary because strict-schema providers may populate every declared property even though JSON Schema marks the action-specific fields optional. 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. @@ -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, 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, required blocker explanations, strict-provider filler fields, rearming after a session-start edge, authority-before-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. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 1a38116035..102337fd57 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,7 +16,7 @@ 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`;每个动作只消费自己的字段并忽略其他字段,而 `blocked` 仍要求非空的 `blocked_reason`,并以稳定代码 `model-reported` 持久化。尽管 JSON Schema 将动作专用字段标记为可选,严格模式提供方仍可能填充所有已声明属性,因此需要这种容忍行为。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、必需的阻塞说明、严格模式提供方填充字段、会话启动边沿后的重新激活、权限先于参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 6b50a5d220..b3f147fdbf 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -470,7 +470,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -495,15 +495,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 7ceeec4042..0e2ebef051 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -413,7 +413,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -438,15 +438,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 4438550b97..6aa7fda0a8 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index eb01e7443e..0d384a55ee 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index d0bd2d5bef..bcdd4c7b99 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -130,7 +130,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private/tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 8f97feff04..25c57ee964 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -78,7 +78,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} {"type":"tool/call","seq":78,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -226,7 +226,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index 5a3fc5696b..f3db3493b5 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -115,7 +115,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-hH2sGY/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"<path>/private/tmp/acp-snap-cwd-hH2sGY/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 888f2f5c13..810083f2a9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index b42a434388..8359d03017 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -434,7 +434,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -459,15 +459,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index ef40784fa5..dac439b700 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index ef40784fa5..dac439b700 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index ef40784fa5..dac439b700 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 529b1419da..0ba75afbdc 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -526,7 +526,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -551,15 +551,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index b01e7683d1..f3739bbc42 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index b01e7683d1..f3739bbc42 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index b01e7683d1..f3739bbc42 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 3f286f8ec3..4fc7cb0ea9 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -6,7 +6,7 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal - `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. `edit` consumes replacement fields, `blocked` requires and persists `blocked_reason` with the stable code `model-reported`, and every action ignores fields it does not consume so providers that populate every schema property cannot prevent a valid transition. All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 009a00376f..5be69a9978 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -248,8 +248,9 @@ export function apply(ctx: Context, config: Config): void { name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' - + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' - + 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.', + + 'and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured ' + + 'minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and ' + + 'must explain it in blocked_reason.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, @@ -259,11 +260,11 @@ export function apply(ctx: Context, config: Config): void { enum: UPDATE_ACTIONS, description: 'edit | pause | resume | complete | blocked', }, - objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, - max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + objective: { type: 'string', description: 'Replacement objective for action edit; ignored for other actions.' }, + max_goal_rounds: { type: 'number', description: 'Replacement round cap for action edit; ignored for other actions.' }, blocked_reason: { type: 'string', - description: 'Concrete blocking condition; required only with action blocked.', + description: 'Concrete blocking condition for action blocked; required for blocked, ignored for other actions.', }, }, output: GOAL_OUTPUT, @@ -276,21 +277,12 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - if (args.blocked_reason !== undefined) { - throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') - } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) return Promise.resolve(goalValue(goal)) } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { - throw new HarnessError( - 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', - 'GOAL_TOOL_INVALID_UPDATE', - ) - } const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) @@ -298,15 +290,6 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { - throw new HarnessError( - 'objective and max_goal_rounds are valid only with action edit', - 'GOAL_TOOL_INVALID_UPDATE', - ) - } - if (args.action === 'complete' && args.blocked_reason !== undefined) { - throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') - } if (args.action === 'blocked' && (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) { throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE') diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 7b2ccdc347..30270fd34d 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -377,26 +377,12 @@ describe('goal tool state transitions', () => { closeTurn(root, turn) }) - it('returns structured domain and conditional-argument failures', async () => { + it('returns structured domain and required-argument failures', async () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE') const created = ctx.goals.create(root.agent, { objective: 'valid' }) - const replacement = await execute(ctx, 'update_goal', { - goal_id: created.id, - revision: created.revision, - action: 'pause', - objective: 'not valid for pause', - }, root.agent) - expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') - const terminalUpdate = await execute(ctx, 'update_goal', { - goal_id: created.id, - revision: created.revision, - action: 'complete', - max_goal_rounds: 2, - }, root.agent) - expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithoutReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', }, root.agent) @@ -405,24 +391,45 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', }, root.agent) expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') - const completeWithReason = await execute(ctx, 'update_goal', { - goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', - }, root.agent) - expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') - const editWithReason = await execute(ctx, 'update_goal', { - goal_id: created.id, - revision: created.revision, - action: 'edit', - objective: 'still valid', - blocked_reason: 'Not valid for edit.', - }, root.agent) - expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', + max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) + it('ignores unused action fields emitted by strict-schema providers', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + let goal = ctx.goals.create(root.agent, { objective: 'valid' }) + const strictArgs = { + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + } + + const paused = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, + }, root.agent) + goal = ctx.goals.get(root.agent)! + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) + const resumed = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, + }, root.agent) + goal = ctx.goals.get(root.agent)! + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'valid' }) + const edited = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'edit', objective: 'edited', + max_goal_rounds: 7, blocked_reason: '', + }, root.agent) + goal = ctx.goals.get(root.agent)! + expect(resultGoal(edited)).toMatchObject({ objective: 'edited', maxGoalRounds: 7 }) + const complete = await execute(ctx, 'update_goal', { + goal_id: goal.id, revision: goal.revision, action: 'complete', ...strictArgs, + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited', maxGoalRounds: 7 }) + }) + it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) From e27a1319ef2d1c60d0d25c96fe216b74919ae505 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 12:59:06 +0800 Subject: [PATCH 063/113] test(goal): align master snapshots --- docs/tool-catalog.md | 20 +++++++------------ .../system-prompt.expected.md | 8 ++++---- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../both-mode-turn/system-prompt.expected.md | 8 ++++---- .../code-mode-turn/system-prompt.expected.md | 8 ++++---- .../system-prompt.expected.md | 8 ++++---- .../escalation-approved/session.jsonl | 4 ++-- .../escalation-rejected/session.jsonl | 4 ++-- .../fs-escalation-approved/session.jsonl | 6 +++--- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 ++-- .../tests/snapshots/plan-mode/session.jsonl | 4 ++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 12 +++++++++-- 13 files changed, 46 insertions(+), 44 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 4f9de3644f..162232c35b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -48,7 +48,6 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Questions to ask the user before continuing.", "items": { "type": "object", - "additionalProperties": true, "properties": { "id": { "type": "string", @@ -67,7 +66,6 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", "items": { "type": "object", - "additionalProperties": true, "properties": { "label": { "type": "string", @@ -231,7 +229,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { @@ -630,7 +628,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. ```json { @@ -657,15 +655,15 @@ Update the exact current goal revision. edit, pause, and resume require a direct }, "objective": { "type": "string", - "description": "Replacement objective; valid only with action edit." + "description": "Replacement objective for action edit; ignored for other actions." }, "max_goal_rounds": { "type": "number", - "description": "Replacement cap; valid only with action edit." + "description": "Replacement round cap for action edit; ignored for other actions." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition; required only with action blocked." + "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." } }, "required": [ @@ -898,7 +896,6 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, "properties": { "content": { "type": "string", @@ -940,7 +937,7 @@ Run a JavaScript workflow script that orchestrates subagents at scale. Use this The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: -- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. +- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. @@ -960,7 +957,6 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", - "additionalProperties": true, "properties": { "name": { "type": "string", @@ -979,7 +975,6 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", - "additionalProperties": true, "properties": { "title": { "type": "string", @@ -1011,8 +1006,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 73c31413bf..cd179628cd 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -190,7 +190,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -198,11 +198,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index eb4488858e..7493d1907c 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-fbfcf2f560a0/1bddd2b64176-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-02b64799b331/1bc5704fddf2-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 8744029272..ce858b897e 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 8744029272..ce858b897e 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 8744029272..ce858b897e 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ + /** Replacement objective for action edit; ignored for other actions. */ objective?: string; - /** Replacement cap; valid only with action edit. */ + /** Replacement round cap for action edit; ignored for other actions. */ max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ + /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 6aa7fda0a8..005b4f4c14 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 0d384a55ee..c4f5658452 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 918b3ab4d5..67f3d15be0 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -90,9 +90,9 @@ {"type":"assistant/chunk","seq":88,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} {"type":"tool/call","seq":90,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","outcome":"allowed-once"}} -{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} +{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","outcome":"allowed-once"}} +{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1784045703799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index dbaaf8a8b6..46de5fa221 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -63,7 +63,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} {"type":"tool/call","seq":63,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-sNvn5N/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"<path>/private/tmp/acp-snap-cwd-sNvn5N/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 810083f2a9..baa85c9d77 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl index 71a1189702..7c1b1a7574 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl +++ b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl @@ -324,7 +324,7 @@ {"type":"assistant/chunk","seq":322,"time":1784525379646,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":323,"time":1784525379647,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The plan is approved. Now I need to apply exactly that one edit: change line 2 of notes.txt to say \"hello world\". I'll use the edit tool to replace the current line 2 content."},{"type":"tool-call","id":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3269,"outputTokens":134,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322],"surfaceOp":"append"} {"type":"tool/call","seq":324,"time":1784525379647,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} +{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} {"type":"step/end","seq":326,"time":1784525379652,"data":{"turn":1,"step":3}} {"type":"step/start","seq":327,"time":1784525379655,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":328,"time":1784525380205,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -428,7 +428,7 @@ {"type":"assistant/chunk","seq":426,"time":1784525381745,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":427,"time":1784525381746,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Now I can apply the edit."},{"type":"tool-call","id":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":169,"outputTokens":98,"cacheReadTokens":3456,"reasoningTokens":7}},"sourceEventSeqs":[372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426],"surfaceOp":"append"} {"type":"tool/call","seq":428,"time":1784525381746,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} +{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} {"type":"step/end","seq":430,"time":1784525381766,"data":{"turn":1,"step":5}} {"type":"step/start","seq":431,"time":1784525381768,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":432,"time":1784525382334,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 30270fd34d..c743f84903 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -409,10 +409,18 @@ describe('goal tool state transitions', () => { } const paused = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, + goal_id: goal.id, + revision: goal.revision, + action: 'pause', + objective: 'ignored replacement', + max_goal_rounds: 1, + blocked_reason: 'ignored blocker', }, root.agent) goal = ctx.goals.get(root.agent)! - expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) + expect(resultGoal(paused)).toMatchObject({ + phase: 'paused', objective: 'valid', maxGoalRounds: goal.maxGoalRounds, + }) + expect(goal.blockedReason).toBeUndefined() const resumed = await execute(ctx, 'update_goal', { goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, }, root.agent) From 1595f4c851aa7da71ba5eaf7d88d70639acafe01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:01:48 +0800 Subject: [PATCH 064/113] refactor(agent-loop): rename private input boundary --- ...7-24-intent-named-agent-delivery.i18n.yaml | 4 +-- .../2026-07-24-intent-named-agent-delivery.md | 2 +- ...26-07-24-intent-named-agent-delivery.zh.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 28 +++++++++---------- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml index 45b232613e..a6220691f1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-intent-named-agent-delivery.md: df58435e5a59dbc5e3c2ba17c624728533ed3b96 -2026-07-24-intent-named-agent-delivery.zh.md: 78cf25870d3f220373692caf943870b920fb151e +2026-07-24-intent-named-agent-delivery.md: 62a05ed7601ca3dd2f05fae609341ab3fa37671a +2026-07-24-intent-named-agent-delivery.zh.md: e559b106ca54f901a18f2936142770d6306ef757 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md index df58435e5a..62a05ed760 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -21,7 +21,7 @@ Sharing helper implementations through an abstract `Agent` class also makes the `send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. -`ReactLoopAgent` resolves each public call into one module-private `ResolvedDelivery` and passes it to native-private `#acceptDelivery`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. +`ReactLoopAgent` resolves each public call into one module-private `ResolvedAgentInput` and passes it to native-private `#acceptInput`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop`. It is not exported, protected, or represented by a base class. With one concrete adapter, a subclass delivery seam would be hypothetical; callers and tests use the same public `Agent` interface. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md index 78cf25870d..e559b106ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -21,7 +21,7 @@ Status: implemented `send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 -`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedDelivery`,再将其传给原生私有的 `#acceptDelivery`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 +`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedAgentInput`,再将其传给原生私有的 `#acceptInput`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出,不是 protected 成员,也不由基类表示。只有一个具体适配器时,子类投递 seam 只是假想的;调用方和测试使用同一个公开 `Agent` 接口。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 68677876f8..cc5adde987 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptDelivery`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptInput`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 8dc6e95808..6d69d2a740 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -42,7 +42,7 @@ const bindContext = Symbol('dsh.agent-loop.bind-context') const publishAgent = Symbol('dsh.agent-loop.publish-agent') /** Fully resolved input accepted only by the concrete driver's private delivery mechanism. */ -type ResolvedDelivery = { +type ResolvedAgentInput = { content: ContentBlock[] source: MessageSource meta: JsonValue | undefined @@ -215,8 +215,8 @@ export class ReactLoopAgent implements Agent { * materialization reads every nested field once; deep freeze prevents later * caller mutation before an inbox or deferred-injection queue drains it. */ - private snapshotMessage(id: AgentMessageId, delivery: ResolvedDelivery): InboxMessage { - const { content, source, contexts, wakeup, meta } = delivery + private snapshotMessage(id: AgentMessageId, input: ResolvedAgentInput): InboxMessage { + const { content, source, contexts, wakeup, meta } = input const accepted = snapshotJsonValue({ id, content, source, contexts, wakeup, ...meta !== undefined ? { meta } : {}, @@ -241,17 +241,17 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - /** Accept one fully resolved intent through the concrete driver's private routing matrix. */ - #acceptDelivery(delivery: ResolvedDelivery): AgentMessageId { + /** Accept one fully resolved agent input through the concrete driver's private routing matrix. */ + #acceptInput(input: ResolvedAgentInput): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) - const { target, wakeup } = delivery + const { target, wakeup } = input // next-step/no-wakeup is injection: durable context without running the model. - if (target === 'next-step' && !wakeup) { this.injectContext(delivery); return id } + if (target === 'next-step' && !wakeup) { this.injectContext(input); return id } // next-step/wakeup is steering into the running turn; idle falls back to a // waking ordinary turn (there is no active turn to attach to). const steering = target === 'next-step' && this._status === 'running' - const accepted = this.snapshotMessage(id, delivery) + const accepted = this.snapshotMessage(id, input) if (steering) { this.#inbox.steer(accepted) } else { @@ -262,7 +262,7 @@ export class ReactLoopAgent implements Agent { } send(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-turn', wakeup: true, @@ -273,7 +273,7 @@ export class ReactLoopAgent implements Agent { } queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-turn', wakeup: false, @@ -284,7 +284,7 @@ export class ReactLoopAgent implements Agent { } steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-step', wakeup: true, @@ -295,7 +295,7 @@ export class ReactLoopAgent implements Agent { } inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { - return this.#acceptDelivery({ + return this.#acceptInput({ content, target: 'next-step', wakeup: false, @@ -306,8 +306,8 @@ export class ReactLoopAgent implements Agent { } /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */ - private injectContext(delivery: Extract<ResolvedDelivery, { target: 'next-step'; wakeup: false }>): void { - const { content, source, meta } = delivery + private injectContext(input: Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>): void { + const { content, source, meta } = input const context = { content, source, From 2e1be7d4d76ac7106c8523ebc1b7844aa4363cc6 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 13:09:13 +0800 Subject: [PATCH 065/113] Revert "test(goal): align master snapshots" This reverts commit 028e63a9eee281a254f113f4721a67343e5521ca. --- docs/tool-catalog.md | 20 ++++++++++++------- .../system-prompt.expected.md | 8 ++++---- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../both-mode-turn/system-prompt.expected.md | 8 ++++---- .../code-mode-turn/system-prompt.expected.md | 8 ++++---- .../system-prompt.expected.md | 8 ++++---- .../escalation-approved/session.jsonl | 4 ++-- .../escalation-rejected/session.jsonl | 4 ++-- .../fs-escalation-approved/session.jsonl | 6 +++--- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 ++-- .../tests/snapshots/plan-mode/session.jsonl | 4 ++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 12 ++--------- 13 files changed, 44 insertions(+), 46 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 162232c35b..4f9de3644f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -48,6 +48,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Questions to ask the user before continuing.", "items": { "type": "object", + "additionalProperties": true, "properties": { "id": { "type": "string", @@ -66,6 +67,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", "items": { "type": "object", + "additionalProperties": true, "properties": { "label": { "type": "string", @@ -229,7 +231,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { @@ -628,7 +630,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. ```json { @@ -655,15 +657,15 @@ Update the exact current goal revision. edit, pause, and resume require a direct }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -896,6 +898,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", + "additionalProperties": true, "properties": { "content": { "type": "string", @@ -937,7 +940,7 @@ Run a JavaScript workflow script that orchestrates subagents at scale. Use this The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: -- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. +- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. @@ -957,6 +960,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "meta": { "type": "object", "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, "properties": { "name": { "type": "string", @@ -975,6 +979,7 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim "description": "Optional phase declarations matched by phase() calls.", "items": { "type": "object", + "additionalProperties": true, "properties": { "title": { "type": "string", @@ -1006,7 +1011,8 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim }, "args": { "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index cd179628cd..73c31413bf 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -190,7 +190,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -198,11 +198,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 7493d1907c..eb4488858e 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-02b64799b331/1bc5704fddf2-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-fbfcf2f560a0/1bddd2b64176-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index ce858b897e..8744029272 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index ce858b897e..8744029272 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index ce858b897e..8744029272 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -173,7 +173,7 @@ interface ToolArgsMap { status: "pending" | "in_progress" | "completed"; } & Record<string, JsonValue>)[]; } & Record<string, JsonValue>; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { /** Exact id returned by get_goal. */ goal_id: string; @@ -181,11 +181,11 @@ interface ToolArgsMap { revision: number; /** edit | pause | resume | complete | blocked */ action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective for action edit; ignored for other actions. */ + /** Replacement objective; valid only with action edit. */ objective?: string; - /** Replacement round cap for action edit; ignored for other actions. */ + /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; - /** Concrete blocking condition for action blocked; required for blocked, ignored for other actions. */ + /** Concrete blocking condition; required only with action blocked. */ blocked_reason?: string; } & Record<string, JsonValue>; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 005b4f4c14..6aa7fda0a8 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"bb75e939-221a-48b0-ad67-3da110ecbd31","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index c4f5658452..0d384a55ee 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"9a997191-81a0-4076-914b-255ebe3e6882","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 67f3d15be0..918b3ab4d5 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -90,9 +90,9 @@ {"type":"assistant/chunk","seq":88,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"} {"type":"tool/call","seq":90,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"f6a28337-87da-4555-bc47-2b5354300cc4","outcome":"allowed-once"}} -{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} +{"type":"approval/asked","seq":91,"time":1784045703782,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":92,"time":1784045703786,"data":{"id":"d7250dff-fc08-4163-9b51-f43f08d6f36f","outcome":"allowed-once"}} +{"type":"tool/result","seq":93,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[90],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1784045703799,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 46de5fa221..dbaaf8a8b6 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -63,7 +63,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} {"type":"tool/call","seq":63,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"<path>/private/tmp/acp-snap-cwd-sNvn5N/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-sNvn5N/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index baa85c9d77..810083f2a9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"c7877002-3d2e-432e-9624-0b9ef3624df3","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl index 7c1b1a7574..71a1189702 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl +++ b/examples/acp-agent/tests/snapshots/plan-mode/session.jsonl @@ -324,7 +324,7 @@ {"type":"assistant/chunk","seq":322,"time":1784525379646,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":323,"time":1784525379647,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The plan is approved. Now I need to apply exactly that one edit: change line 2 of notes.txt to say \"hello world\". I'll use the edit tool to replace the current line 2 content."},{"type":"tool-call","id":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3269,"outputTokens":134,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322],"surfaceOp":"append"} {"type":"tool/call","seq":324,"time":1784525379647,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} +{"type":"tool/result","seq":325,"time":1784525379652,"data":{"turn":1,"step":3,"callId":"call_00_eYuZ6fAuzqr8vtFd3oRg2452","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[324],"surfaceOp":"append"} {"type":"step/end","seq":326,"time":1784525379652,"data":{"turn":1,"step":3}} {"type":"step/start","seq":327,"time":1784525379655,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":328,"time":1784525380205,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -428,7 +428,7 @@ {"type":"assistant/chunk","seq":426,"time":1784525381745,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":427,"time":1784525381746,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Now I can apply the edit."},{"type":"tool-call","id":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":169,"outputTokens":98,"cacheReadTokens":3456,"reasoningTokens":7}},"sourceEventSeqs":[372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426],"surfaceOp":"append"} {"type":"tool/call","seq":428,"time":1784525381746,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","name":"edit","arguments":"{\"file_path\": \"notes.txt\", \"old_string\": \"- the greeting message still says \\\"helo wrld\\\"\", \"new_string\": \"hello world\"}"}} -{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} +{"type":"tool/result","seq":429,"time":1784525381766,"data":{"turn":1,"step":5,"callId":"call_00_7H6VFdLjBqzzW9M5Z4OR8016","content":[{"type":"text","text":"The file /var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-tia05W/notes.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"notes.txt","oldText":"project notes\n- the greeting message still says \"helo wrld\"","newText":"project notes\nhello world"}]}},"sourceEventSeqs":[428],"surfaceOp":"append"} {"type":"step/end","seq":430,"time":1784525381766,"data":{"turn":1,"step":5}} {"type":"step/start","seq":431,"time":1784525381768,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":432,"time":1784525382334,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index c743f84903..30270fd34d 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -409,18 +409,10 @@ describe('goal tool state transitions', () => { } const paused = await execute(ctx, 'update_goal', { - goal_id: goal.id, - revision: goal.revision, - action: 'pause', - objective: 'ignored replacement', - max_goal_rounds: 1, - blocked_reason: 'ignored blocker', + goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, }, root.agent) goal = ctx.goals.get(root.agent)! - expect(resultGoal(paused)).toMatchObject({ - phase: 'paused', objective: 'valid', maxGoalRounds: goal.maxGoalRounds, - }) - expect(goal.blockedReason).toBeUndefined() + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) const resumed = await execute(ctx, 'update_goal', { goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, }, root.agent) From 0d413490021936d640320f076af0bfef248fa949 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 13:09:13 +0800 Subject: [PATCH 066/113] Revert "fix(goal): tolerate strict provider filler fields" This reverts commit 9a30dbb0f537f512180754c4778c15f9f94f1a95. --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 4 +- .../2026-07-19-model-facing-goal-tools.zh.md | 4 +- .../tool-schemas.expected.json | 8 +-- .../both-mode-turn/tool-schemas.expected.json | 8 +-- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../tests/snapshots/fs-edit/session.jsonl | 2 +- .../snapshots/fs-policy-reject/session.jsonl | 4 +- .../fs-write-overwrite/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- .../lsp-definition/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 16 ++--- .../tool-schemas.expected.json | 16 ++--- .../plan-mode/tool-schemas.expected.json | 16 ++--- .../pty-tools/tool-schemas.expected.json | 8 +-- .../skill-load/tool-schemas.expected.json | 8 +-- .../text-turn/tool-schemas.expected.json | 8 +-- .../tool-schemas.expected.json | 8 +-- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/src/index.ts | 29 +++++++-- .../goal/tool-goal/tests/tool-goal.spec.ts | 61 ++++++++----------- 22 files changed, 119 insertions(+), 109 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index b91a8758ca..e53c591aa5 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 7df04678ab8f78be71504b9a0d6e3a05391833e3 -2026-07-19-model-facing-goal-tools.zh.md: 102337fd57291102ce16a3978bafe9fcf62b9695 +2026-07-19-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b +2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7df04678ab..7cc3907d70 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,7 +16,7 @@ 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`; each action consumes only its fields and ignores the others, while `blocked` still requires a non-empty `blocked_reason` and persists it under the stable `model-reported` code. This tolerance is necessary because strict-schema providers may populate every declared property even though JSON Schema marks the action-specific fields optional. +`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 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. @@ -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, required blocker explanations, strict-provider filler fields, rearming after a session-start edge, authority-before-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, 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. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 102337fd57..1a38116035 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,7 +16,7 @@ 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`;每个动作只消费自己的字段并忽略其他字段,而 `blocked` 仍要求非空的 `blocked_reason`,并以稳定代码 `model-reported` 持久化。尽管 JSON Schema 将动作专用字段标记为可选,严格模式提供方仍可能填充所有已声明属性,因此需要这种容忍行为。 +`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` 持久化。 提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、必需的阻塞说明、严格模式提供方填充字段、会话启动边沿后的重新激活、权限先于参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index b3f147fdbf..6b50a5d220 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -470,7 +470,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -495,15 +495,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 0e2ebef051..7ceeec4042 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -413,7 +413,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -438,15 +438,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 6aa7fda0a8..4438550b97 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -132,8 +132,8 @@ {"type":"assistant/chunk","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} {"type":"tool/call","seq":132,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"21634890-d003-489f-b1b5-d38e187561c5","outcome":"allowed-once"}} +{"type":"approval/asked","seq":133,"time":1783962245386,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":134,"time":1783962245387,"data":{"id":"efa76e4a-8e3e-4be0-9608-a68def7354b9","outcome":"allowed-once"}} {"type":"tool/result","seq":135,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":136,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":137,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 0d384a55ee..eb01e7443e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -156,8 +156,8 @@ {"type":"assistant/chunk","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154],"surfaceOp":"append"} {"type":"tool/call","seq":156,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"4ffb95e3-8f94-41db-bfd3-2f56e6631ef8","outcome":"rejected"}} +{"type":"approval/asked","seq":157,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":158,"time":1783962246275,"data":{"id":"e5cc84ef-8704-4cd9-8e47-897a0a51beb6","outcome":"rejected"}} {"type":"tool/result","seq":159,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[156],"surfaceOp":"append"} {"type":"step/end","seq":160,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":161,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index bcdd4c7b99..d0bd2d5bef 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -130,7 +130,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private/tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 25c57ee964..8f97feff04 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -78,7 +78,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} {"type":"tool/call","seq":78,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -226,7 +226,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index f3db3493b5..5a3fc5696b 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -115,7 +115,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"<path>/private/tmp/acp-snap-cwd-hH2sGY/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-hH2sGY/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 810083f2a9..888f2f5c13 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -56,8 +56,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"e629ff8c-60d2-49b1-9603-333b16ef3306","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"97616288-1a5e-4110-a75d-7616a24adcc4","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 8359d03017..b42a434388 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -434,7 +434,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -459,15 +459,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index dac439b700..ef40784fa5 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index dac439b700..ef40784fa5 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index dac439b700..ef40784fa5 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -947,7 +947,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -972,15 +972,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 0ba75afbdc..529b1419da 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -526,7 +526,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -551,15 +551,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index f3739bbc42..b01e7683d1 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index f3739bbc42..b01e7683d1 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index f3739bbc42..b01e7683d1 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -397,7 +397,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -422,15 +422,15 @@ }, "objective": { "type": "string", - "description": "Replacement objective for action edit; ignored for other actions." + "description": "Replacement objective; valid only with action edit." }, "max_goal_rounds": { "type": "number", - "description": "Replacement round cap for action edit; ignored for other actions." + "description": "Replacement cap; valid only with action edit." }, "blocked_reason": { "type": "string", - "description": "Concrete blocking condition for action blocked; required for blocked, ignored for other actions." + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 4fc7cb0ea9..3f286f8ec3 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -6,7 +6,7 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal - `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. `edit` consumes replacement fields, `blocked` requires and persists `blocked_reason` with the stable code `model-reported`, and every action ignores fields it does not consume so providers that populate every schema property cannot prevent a valid transition. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 5be69a9978..009a00376f 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -248,9 +248,8 @@ export function apply(ctx: Context, config: Config): void { name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' - + 'and blocked are also allowed. Fields not used by the selected action are ignored. blocked is rejected before the configured ' - + 'minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and ' - + 'must explain it in blocked_reason.', + + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' + + 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, @@ -260,11 +259,11 @@ export function apply(ctx: Context, config: Config): void { enum: UPDATE_ACTIONS, description: 'edit | pause | resume | complete | blocked', }, - objective: { type: 'string', description: 'Replacement objective for action edit; ignored for other actions.' }, - max_goal_rounds: { type: 'number', description: 'Replacement round cap for action edit; ignored for other actions.' }, + objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, + max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, blocked_reason: { type: 'string', - description: 'Concrete blocking condition for action blocked; required for blocked, ignored for other actions.', + description: 'Concrete blocking condition; required only with action blocked.', }, }, output: GOAL_OUTPUT, @@ -277,12 +276,21 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + if (args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) return Promise.resolve(goalValue(goal)) } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) + if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) @@ -290,6 +298,15 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + if (args.action === 'complete' && args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } if (args.action === 'blocked' && (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) { throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE') diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 30270fd34d..7b2ccdc347 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -377,12 +377,26 @@ describe('goal tool state transitions', () => { closeTurn(root, turn) }) - it('returns structured domain and required-argument failures', async () => { + it('returns structured domain and conditional-argument failures', async () => { const { ctx, root } = await harness() openTurn(root, { kind: 'user' }) const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) expect(invalidCreate.error?.info?.code).toBe('GOAL_INVALID_OBJECTIVE') const created = ctx.goals.create(root.agent, { objective: 'valid' }) + const replacement = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'pause', + objective: 'not valid for pause', + }, root.agent) + expect(replacement.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const terminalUpdate = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'complete', + max_goal_rounds: 2, + }, root.agent) + expect(terminalUpdate.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const blockedWithoutReason = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'blocked', }, root.agent) @@ -391,45 +405,24 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', }, root.agent) expect(blockedWithEmptyReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const completeWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', + }, root.agent) + expect(completeWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const editWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'edit', + objective: 'still valid', + blocked_reason: 'Not valid for edit.', + }, root.agent) + expect(editWithReason.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', - max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) - it('ignores unused action fields emitted by strict-schema providers', async () => { - const { ctx, root } = await harness() - openTurn(root, { kind: 'user' }) - let goal = ctx.goals.create(root.agent, { objective: 'valid' }) - const strictArgs = { - objective: '', - max_goal_rounds: 0, - blocked_reason: '', - } - - const paused = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'pause', ...strictArgs, - }, root.agent) - goal = ctx.goals.get(root.agent)! - expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'valid' }) - const resumed = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'resume', ...strictArgs, - }, root.agent) - goal = ctx.goals.get(root.agent)! - expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'valid' }) - const edited = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'edit', objective: 'edited', - max_goal_rounds: 7, blocked_reason: '', - }, root.agent) - goal = ctx.goals.get(root.agent)! - expect(resultGoal(edited)).toMatchObject({ objective: 'edited', maxGoalRounds: 7 }) - const complete = await execute(ctx, 'update_goal', { - goal_id: goal.id, revision: goal.revision, action: 'complete', ...strictArgs, - }, root.agent) - expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited', maxGoalRounds: 7 }) - }) - it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) From d436074cc23f2b304ae97198b968f25e7fb14c86 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 13:15:39 +0800 Subject: [PATCH 067/113] fix(goal): accept empty update fillers --- packages/goal/tool-goal/src/index.ts | 35 ++++++++++- .../goal/tool-goal/tests/tool-goal.spec.ts | 59 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 009a00376f..91ca254b2d 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,6 +132,32 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } +/** Remove only empty provider fillers from fields unused by the selected action. */ +function normalizeUpdateArgs(args: Record<string, unknown>): Record<string, unknown> { + const normalized = { ...args } + const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 + const removeEmpty = (key: string): void => { + if (empty(normalized[key])) normalized[key] = undefined + } + switch (normalized['action']) { + case 'edit': + removeEmpty('blocked_reason') + break + case 'blocked': + removeEmpty('objective') + removeEmpty('max_goal_rounds') + break + case 'pause': + case 'resume': + case 'complete': + removeEmpty('objective') + removeEmpty('max_goal_rounds') + removeEmpty('blocked_reason') + break + } + return normalized +} + /** Build the exact compare-and-set ref from model arguments. */ function goalRef(goalId: string, revision: number): GoalRef { if (goalId.length === 0 || goalId !== goalId.trim() @@ -244,7 +270,7 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present('Create goal', 'other', args.objective), })) - ctx.tools.register(defineTool({ + const updateGoal = defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' @@ -333,5 +359,10 @@ export function apply(ctx: Context, config: Config): void { 'other', args.blocked_reason ?? args.objective ?? args.goal_id, ), - })) + }) + // Object-literal execute methods do not use `this`; retaining the reference is safe. + // eslint-disable-next-line @typescript-eslint/unbound-method + const executeUpdate = updateGoal.execute + updateGoal.execute = (args, exec) => executeUpdate(normalizeUpdateArgs(args as Record<string, unknown>), exec) + ctx.tools.register(updateGoal) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 7b2ccdc347..a1261d3cb8 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -423,6 +423,65 @@ describe('goal tool state transitions', () => { expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE') }) + it('accepts only empty fillers in fields unused by the selected action', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + let goal = ctx.goals.create(root.agent, { objective: 'valid' }) + + const edited = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'edit', + objective: 'edited', + blocked_reason: '', + }, root.agent) + expect(resultGoal(edited)).toMatchObject({ objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const paused = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'pause', + objective: '', + max_goal_rounds: 0, + blocked_reason: null, + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const resumed = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'resume', + objective: null, + max_goal_rounds: '', + blocked_reason: '', + }, root.agent) + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' }) + goal = ctx.goals.get(root.agent)! + + const blocked = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'blocked', + objective: '', + max_goal_rounds: null, + blocked_reason: 'actual blocker', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' }) + goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 }) + + const complete = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'complete', + objective: '', + max_goal_rounds: 0, + blocked_reason: '', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' }) + }) + it('allows exact goal rounds to complete but not edit or pause', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) From d94a4916f1c8a43a2c4b3c194bd183814edf52d0 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 13:16:14 +0800 Subject: [PATCH 068/113] fix(goal): preserve normalized argument shape --- packages/goal/tool-goal/src/index.ts | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 91ca254b2d..a0df65a6ed 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -134,28 +134,12 @@ function resolveConfig(config: Config): ResolvedConfig { /** Remove only empty provider fillers from fields unused by the selected action. */ function normalizeUpdateArgs(args: Record<string, unknown>): Record<string, unknown> { - const normalized = { ...args } + const action = args['action'] const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 - const removeEmpty = (key: string): void => { - if (empty(normalized[key])) normalized[key] = undefined - } - switch (normalized['action']) { - case 'edit': - removeEmpty('blocked_reason') - break - case 'blocked': - removeEmpty('objective') - removeEmpty('max_goal_rounds') - break - case 'pause': - case 'resume': - case 'complete': - removeEmpty('objective') - removeEmpty('max_goal_rounds') - removeEmpty('blocked_reason') - break - } - return normalized + const unused = (key: string): boolean => key === 'blocked_reason' + ? action !== 'blocked' + : (key === 'objective' || key === 'max_goal_rounds') && action !== 'edit' + return Object.fromEntries(Object.entries(args).filter(([key, value]) => !unused(key) || !empty(value))) } /** Build the exact compare-and-set ref from model arguments. */ From f97f9bcf8a71fdc6c81cc6c3fc72a5042c6ce392 Mon Sep 17 00:00:00 2001 From: NI0317 <stniii317@gmail.com> Date: Fri, 24 Jul 2026 13:23:10 +0800 Subject: [PATCH 069/113] chore(examples): declare @deepseek-ai/dsh-llm-pi-ai as an example dep pi-ai is the library-backed twin adapter the tui-agent README already points at ("swap one line to @deepseek-ai/dsh-llm-pi-ai"), and the supported entry point for third-party providers (Anthropic, Google, OpenRouter) mounted through the personal overlay under ~/.dsh. Making it a declared workspace dep of the examples umbrella means `pnpm install` resolves the symlink upstream so users configuring a third-party provider via `~/.dsh/config.yaml` don't have to patch `examples/package.json` locally (which their next git checkout would wipe). Placement matches the sibling llm-* cluster; workspace:* to match the other adapters. No cordis.yml or README changes: mounting pi-ai remains explicit and opt-in per the provider-routed-llm-adapters Agent Note. --- examples/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/examples/package.json b/examples/package.json index 1b4e28c4fd..bd87263340 100644 --- a/examples/package.json +++ b/examples/package.json @@ -26,6 +26,7 @@ "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..aff769a93e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:* + version: link:../packages/llm/llm-pi-ai '@deepseek-ai/dsh-llm-replay': specifier: workspace:* version: link:../packages/support/llm-replay From 3babb2cd423de28d409e545d8ef4822d5abb3881 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 13:30:19 +0800 Subject: [PATCH 070/113] feat(trajectory): implement trajectory step cell and layout with bilingual support - Added TrajectoryCell, TrajectoryGroupHeader, and TrajectoryTurn components for rendering trajectory steps and groups. - Introduced bilingual support with English and Chinese translations for trajectory notes. - Updated conversation session models to include timestamps for various message types. - Enhanced layout logic to handle expanded assistant blocks and tool results with duration metrics. - Added CSS styles for new components to ensure proper display and alignment. --- .../2026-07-23-trajectory-step-cell.i18n.yaml | 6 + .../2026-07-23-trajectory-step-cell.md | 35 ++ .../2026-07-23-trajectory-step-cell.zh.md | 35 ++ .../src/client/sessions/conversation.ts | 16 + .../src/client/sessions/fold-adapter.ts | 28 +- .../runtime/src/client/sessions/session.ts | 9 +- .../tests/chat-stats-bash-sample.spec.tsx | 7 +- .../tests/chat-tool-row.spec.tsx | 5 +- .../ui-conversation/tests/chat-view.spec.tsx | 9 +- .../tests/coverage-tails.spec.tsx | 6 +- .../tests/skeleton-branches.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 9 +- .../ui-theme/src/styles/design-platform.css | 5 +- packages/client/ui-trajectory/README.md | 4 +- .../src/client/TrajectoryCell.module.css | 93 +++++ .../src/client/TrajectoryCell.tsx | 99 +++++ .../client/TrajectoryGroupHeader.module.css | 27 ++ .../src/client/TrajectoryGroupHeader.tsx | 26 ++ .../src/client/TrajectoryTurn.module.css | 16 + .../src/client/TrajectoryTurn.tsx | 26 ++ .../client/TrajectoryTurnHeader.module.css | 48 +++ .../src/client/TrajectoryTurnHeader.tsx | 30 ++ .../src/client/TrajectoryView.tsx | 50 ++- .../client/ui-trajectory/src/client/index.ts | 4 +- .../client/ui-trajectory/src/client/layout.ts | 374 ++++++++++++++++++ .../ui-trajectory/src/client/views.module.css | 18 +- .../client/ui-trajectory/tests/cell.spec.tsx | 87 ++++ .../ui-trajectory/tests/layout.spec.tsx | 143 +++++++ .../client/ui-trajectory/tests/views.spec.tsx | 41 +- 29 files changed, 1190 insertions(+), 70 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryCell.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryCell.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/layout.ts create mode 100644 packages/client/ui-trajectory/tests/cell.spec.tsx create mode 100644 packages/client/ui-trajectory/tests/layout.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml new file mode 100644 index 0000000000..fb39ae1301 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d +2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55 diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md new file mode 100644 index 0000000000..42d896b81a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory step cell and turn list chrome + +Status: implemented + +English | [中文](2026-07-23-trajectory-step-cell.zh.md) + +## Problem + +The trajectory tab needs a reusable step row and turn-list chrome that can show expanded assistant blocks, own-duration times, Message token columns, and in-flight work. Without folding session event times into conversation nodes and expanding blocks into cells, the UI cannot match the product chrome. + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) owns the presentational trajectory list chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection. +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8. +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). + +## Alternatives considered + +**Keep a Think cell for reasoning blocks.** Rejected: a single `assistant/message.time` cannot yield Think own-duration without chunk-level clocks; omit the row rather than show `—`. + +**Keep separate Call and Result rows.** Rejected: Result had no own duration to show; one Tool row carries the call→result interval. + +**Cumulative elapsed from session/turn start.** Rejected; the Time column is each row's own duration. + +**Hang usage on the first expanded row.** Rejected; usage attaches to Message only. + +**Show in-flight tool durations via Date.now().** Deferred; in-flight Time stays `—`. + +## Consequences + +The Trajectory tab can render expanded finalized and in-flight rows with own-duration times once fold emits `time`. Behavior-shaped coverage lives in `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`. Chat selection deep-links and finer block-level clocks remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md new file mode 100644 index 0000000000..c6bcde7deb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory 步骤单元格与轮次列表 chrome + +Status: implemented + +[English](2026-07-23-trajectory-step-cell.md) | 中文 + +## Problem + +trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展示展开后的 assistant 块、自身耗时、Message token 列,以及进行中的工作。若不将会话事件时间折叠进会话节点,并将块展开为单元格,UI 就无法对齐产品 chrome。 + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) 拥有展示型 trajectory 列表 chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。 +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。 +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。 + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 + +## Alternatives considered + +**为 reasoning 块保留 Think 单元格。** 否决:单条 `assistant/message.time` 无法给出 Think 自身耗时(除非上 chunk 级时钟);与其显示 `—`,不如省略该行。 + +**保留分开的 Call 与 Result 行。** 否决:Result 没有可展示的自身耗时;一行 Tool 承载 call→result 区间。 + +**自会话/轮次起点累计耗时。** 否决;Time 列是每行自身的耗时。 + +**将用量挂在展开后的第一行。** 否决;用量仅附着于 Message。 + +**用 Date.now() 显示进行中工具的耗时。** 延后;进行中的 Time 保持为 `—`。 + +## Consequences + +一旦 fold 发出 `time`,Trajectory 标签页即可渲染带自身耗时的已定稿与进行中展开行。行为导向的覆盖位于 `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`。chat 选中深链与更细的块级时钟仍延后。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1e06fad70d..08b80f2a26 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { export interface UserMessageNode { kind: 'user' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown } @@ -50,6 +52,8 @@ export interface UserMessageNode { export interface AssistantMessageNode { kind: 'assistant' seq: number + /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ + time: number turn: number step: number blocks: readonly AssistantBlock[] @@ -63,6 +67,8 @@ export interface AssistantMessageNode { export interface SteeringMessageNode { kind: 'steering' seq: number + /** Unix epoch ms from the source session event. */ + time: number turn: number content: readonly ContentBlock[] source: unknown @@ -72,6 +78,8 @@ export interface SteeringMessageNode { export interface ContextMessageNode { kind: 'context' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown meta?: unknown @@ -81,9 +89,13 @@ export interface ContextMessageNode { export interface ToolResultNode { kind: 'tool-result' seq: number + /** Unix epoch ms from the tool/result session event. */ + time: number callId: string /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ call: { name: string; argsRaw: string } | null + /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ + callTime: number | null content: readonly ContentBlock[] isError: boolean error?: { name: string; code: string } @@ -98,6 +110,8 @@ export interface ToolResultNode { export interface UnknownSurfaceNode { kind: 'unknown' seq: number + /** Unix epoch ms from the source session event when known. */ + time: number type: string data: unknown } @@ -118,6 +132,8 @@ export interface RunningToolCall { argsRaw: string turn: number step: number + /** Unix epoch ms when the tool/call event was logged. */ + time: number /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */ callView: ToolCallView | null } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index ccb48a0161..0342bc3c85 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -18,6 +18,8 @@ export interface CallIndexEntry { argsRaw: string turn: number step: number + /** Unix epoch ms of the tool/call event. */ + time: number /** Wire view riding the tool/call (envelope-level; never inside the event). */ callView: ToolCallView | null } @@ -38,24 +40,34 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': - return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } + return { + kind: 'user', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } case 'assistant/message': return { - kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step, + kind: 'assistant', seq: event.seq, time: event.time, + turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, } case 'steering/message': - return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source } + return { + kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + content: event.data.content, source: event.data.source, + } case 'context/message': return { - kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, + kind: 'context', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, meta: event.data.meta, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) return { - kind: 'tool-result', seq: event.seq, callId: String(event.data.callId), + kind: 'tool-result', seq: event.seq, time: event.time, + callId: String(event.data.callId), call: call ? { name: call.name, argsRaw: call.argsRaw } : null, + callTime: call?.time ?? null, content: event.data.content, isError: event.data.isError, ...(event.data.error !== undefined ? { error: event.data.error } : {}), meta: event.data.meta, @@ -67,7 +79,10 @@ function materializeNode( surface-eligible types, and each has a case above; reachable only if core adds an eligible type. */ default: - return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data } + return { + kind: 'unknown', seq: event.seq, time: event.time, + type: event.type, data: (event as { data?: unknown }).data, + } } } @@ -186,6 +201,7 @@ export class FoldAdapter { if (event.type !== 'tool/call') return this.callIdx.set(String(event.data.callId), { name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, + time: event.time, callView: view?.for === 'call' ? view.view : null, }) // No backfill into already-materialized tool-result nodes for this callId diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index c394141d85..6b773e0903 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> { case 'tool/call': { this.openCalls.set(String(event.data.callId), { callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, - turn: event.data.turn, step: event.data.step, + turn: event.data.turn, step: event.data.step, time: event.time, callView: view?.for === 'call' ? view.view : null, }) this.callsRev++ @@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> { if (visible) { // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. this.frozenNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step, + kind: 'assistant', seq: event.seq - 0.9, time: event.time, + turn: this.partial.turn, step: this.partial.step, blocks, interrupted: true, }) this.frozenRev++ @@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> { this.callsRev++ // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). this.frozenNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId, + kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, + callId, call: { name: call.name, argsRaw: call.argsRaw }, + callTime: call.time, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 2686a59ac2..4d3383b2d1 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -20,7 +20,7 @@ afterEach(cleanup) const SID = 's1' as SessionId const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({ - kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], + kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], ...(usage === undefined ? {} : { usage }), }) @@ -65,7 +65,7 @@ describe('deriveStats', () => { it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => { const tool: ToolResultNode = { - kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [], + kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], isError: false, callView: null, resultView: null, } const stats = deriveStats([tool, assistant(1, 1)]) @@ -112,8 +112,9 @@ describe('bash sample row', () => { const CHILD = 'child-1' as SessionId const result = (callId: string): ToolResultNode => ({ - kind: 'tool-result', seq: 3, callId, + kind: 'tool-result', seq: 3, time: 3_000, callId, call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, + callTime: 2_000, content: [], isError: false, callView: null, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 828cf586fe..a221a4028b 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/ const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({ callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', - turn: 1, step: 1, callView: null, ...over, + turn: 1, step: 1, time: 1_000, callView: null, ...over, }) const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({ - kind: 'tool-result', seq: 10, callId: 'c1', + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, ...over, }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 90c2f3090c..3f1db55199 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -54,18 +54,19 @@ function makeSource(init?: Partial<ConversationSnapshot>) { } const user = (seq: number, text: string): UserMessageNode => ({ - kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null, + kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null, }) const assistant = (seq: number, text: string): AssistantMessageNode => ({ - kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }], + kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], }) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ - kind: 'tool-result', seq, callId, + kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, content: [], isError: false, callView: null, resultView: null, }) const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null, + callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) /** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */ diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 7f98cdd6ed..66ae3e802c 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -62,8 +62,9 @@ describe('tails', () => { it('a settled others-variant row renders the sparkle icon in the leading slot', () => { const settled: ToolResultNode = { - kind: 'tool-result', seq: 2, callId: 'c5', + kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, } const props: ToolRowOwnerProps = { @@ -77,8 +78,9 @@ describe('tails', () => { it('BashRow shows the failed pill on error results (root session arm)', () => { const errorResult: ToolResultNode = { - kind: 'tool-result', seq: 1, callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"boom"}' }, + callTime: 500, content: [], isError: true, callView: null, resultView: null, } // Root session (no parentId): the global arm renders, error pill visible. diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b91fe229c3..10dba860c0 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -165,7 +165,7 @@ describe('DetailsPanel branches', () => { it('shows non-JSON args verbatim (streaming fragment path)', () => { const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, { - runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }], + runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }], }) expect(view.getByText('{"cmd": tru')).toBeTruthy() }) @@ -176,7 +176,7 @@ describe('DetailsPanel branches', () => { }) it('snapshot updates re-run the material selector through the shallow equality arm', () => { - let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot + let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot const subs = new Set<() => void>() const source = { getSnapshot: () => snap, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a4243c8cb7..c923c19269 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -234,10 +234,11 @@ describe('DetailsPanel', () => { it('renders the selected call args and result off the shared store; close fires the injected callback', () => { const { closeDetails } = benchDetails({ nodes: [{ - kind: 'tool-result', callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"cmd":"ls"}' }, + callTime: 500, content: [{ type: 'text', text: 'file-a\nfile-b' }], - isError: false, + isError: false, callView: null, resultView: null, }], }, { turnSeq: 1, callId: 'c1' }) expect(screen.getByText('bash')).toBeTruthy() @@ -248,10 +249,10 @@ describe('DetailsPanel', () => { }) it('shows the empty hint without a selection and the running state for open calls', () => { - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null) expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy() cleanup() - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' }) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' }) expect(screen.getByText('运行中…')).toBeTruthy() }) diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 96408ce4b9..e00ec415b7 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -169,6 +169,7 @@ body { --dsw-alias-border-l3: rgba(0, 0, 0, 0.12); --dsw-alias-border-l4: rgba(0, 0, 0, 0.16); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700); @@ -217,6 +218,7 @@ body { --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-success-primary: var(--dsw-static-green-500); --dsw-alias-state-success-secondary: var(--dsw-static-green-400); + --dsw-alias-state-success-tertiary: var(--dsw-static-green-100); --dsw-alias-state-warn-label: var(--dsw-static-amber-600); --dsw-alias-state-warn-primary: var(--dsw-static-amber-500); --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); @@ -257,11 +259,12 @@ body[data-ds-dark-theme] { --dsw-alias-border-l3: rgba(255, 255, 255, 0.16); --dsw-alias-border-l4: rgba(255, 255, 255, 0.2); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50); + --dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750); - --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-950); + --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850); --dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800); --dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600); --dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750); diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index e3c2f6aade..f99a5c8386 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-trajectory -Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience @@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Both views are placeholders by charter** — coarse span derivation with no visual acceptance bar; the real implementations, anchor deep-linking, and span-click selection handoff are the P-III project. +- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred. diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css new file mode 100644 index 0000000000..1120fe2746 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -0,0 +1,93 @@ +/* Trajectory step cell — 38px row: index · kind tag · text · optional message + * metrics · elapsed time. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 38px; + padding: 0 8px 0 20px; + gap: 24px; + border-radius: 8px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-3); + min-width: 0; +} + +.selected { + border-color: transparent; + box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.index { + flex: none; + width: 24px; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + +.tagSlot { + flex: none; + width: 80px; + display: flex; + align-items: center; + min-width: 0; +} + +.tag { + display: inline-flex; + align-items: center; + box-sizing: border-box; + height: 22px; + max-width: 100%; + padding: 0 4px; + border-radius: 6px; + font: var(--dsw-font-xs-strong-13); + white-space: nowrap; +} + +.tagUser { + color: var(--dsw-alias-state-success-primary); + background: var(--dsw-alias-state-success-tertiary); +} + +.tagMessage { + color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); + background: var(--dsw-specific-bubble); +} + +.tagTool { + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-warn-tertiary); +} + +.text { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */ +.trailing { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + width: 320px; + gap: 12px; + min-width: 0; +} + +.metric, +.time { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx new file mode 100644 index 0000000000..de99d027d8 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -0,0 +1,99 @@ +// TrajectoryCell: one step row in the trajectory list — index, kind tag, +// ellipsis text, optional Message token metrics, and own-duration time. + +import type { HTMLAttributes } from 'react' +import css from './TrajectoryCell.module.css' + +/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' + +/** Display label per kind (matches the design tags). */ +const KIND_LABEL: Record<TrajectoryCellKind, string> = { + user: 'User', + message: 'Message', + tool: 'Tool', +} + +const TAG_CLASS: Record<TrajectoryCellKind, string> = { + user: css.tagUser!, + message: css.tagMessage!, + tool: css.tagTool!, +} + +export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> { + /** 1-based step index shown as `#N`. */ + index: number + kind: TrajectoryCellKind + /** Single-line summary; CSS ellipsis when it overflows. */ + text: string + /** + * Own duration in seconds. `null` means no duration to show (em dash) — + * used for in-flight tools and tools missing callTime. + */ + timeSeconds: number | null + /** Message-only: prompt token count. */ + input?: number + /** Message-only: completion token count. */ + output?: number + /** Message-only: reasoning token count (usage column, not a Think cell). */ + think?: number + /** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */ + selected?: boolean +} + +/** + * Format own-duration for the trailing time column: `—` when unknown, `+Ns` + * or `+N.1s` otherwise. + * @param seconds - duration seconds, or null when absent. + * @returns display string. + */ +export function formatElapsedSeconds(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return '—' + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `+${rounded}s` + return `+${rounded.toFixed(1)}s` +} + +/** + * Render one trajectory step cell. + * @param props - index, kind, text, time, and optional Message metrics. + * @returns the cell element. + */ +export function TrajectoryCell({ + index, + kind, + text, + timeSeconds, + input, + output, + think, + selected = false, + className, + ...rest +}: TrajectoryCellProps) { + const rootClass = [ + css.root, + selected ? css.selected : undefined, + className, + ].filter((c): c is string => c !== undefined).join(' ') + const showMetrics = kind === 'message' + return ( + <div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}> + <span className={css.index}>#{index}</span> + <span className={css.tagSlot}> + <span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span> + </span> + <span className={css.text}>{text}</span> + <span className={css.trailing}> + {showMetrics ? ( + <> + <span className={css.metric}>{input ?? ''}</span> + <span className={css.metric}>{output ?? ''}</span> + <span className={css.metric}>{think ?? ''}</span> + </> + ) : null} + <span className={css.time}>{formatElapsedSeconds(timeSeconds)}</span> + </span> + </div> + ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css new file mode 100644 index 0000000000..6de7074aaa --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css @@ -0,0 +1,27 @@ +/* Message / Step group title row inside a turn body. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 36px; + padding: 0 20px; + gap: 24px; + min-width: 0; +} + +.title { + flex: none; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +.description { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx new file mode 100644 index 0000000000..90252ce373 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx @@ -0,0 +1,26 @@ +// TrajectoryGroupHeader: "Message" or "Step N" row with optional description. + +import css from './TrajectoryGroupHeader.module.css' + +export interface TrajectoryGroupHeaderProps { + /** Group title (`Message`, `Step 1`, …). */ + title: string + /** Secondary summary (`49s`, `2.2s skill`, …). */ + description?: string +} + +/** + * Render a Message/Step group header inside a turn body. + * @param props - title and optional description. + * @returns the group header element. + */ +export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) { + return ( + <div className={css.root}> + <span className={css.title}>{title}</span> + {description !== undefined && description !== '' + ? <span className={css.description}>{description}</span> + : null} + </div> + ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css new file mode 100644 index 0000000000..c1f243c8b9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css @@ -0,0 +1,16 @@ +/* One turn block: sticky header + padded body with 10px item gap. */ + +.root { + width: 100%; +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + box-sizing: border-box; + width: 100%; + max-width: 880px; + margin: 0 auto; + padding: 8px 16px 22px; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx new file mode 100644 index 0000000000..6ebce17731 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx @@ -0,0 +1,26 @@ +// TrajectoryTurn: sticky Turn header plus the padded Message/Step body. + +import type { ReactNode } from 'react' +import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx' +import css from './TrajectoryTurn.module.css' + +export interface TrajectoryTurnProps { + /** 1-based turn index for the sticky header. */ + turn: number + /** Message / Step headers and TrajectoryCell rows. */ + children?: ReactNode +} + +/** + * Render one turn section (sticky header + body). + * @param props - turn index and body children. + * @returns the turn section element. + */ +export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) { + return ( + <section className={css.root} data-turn={turn}> + <TrajectoryTurnHeader turn={turn} /> + <div className={css.body}>{children}</div> + </section> + ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css new file mode 100644 index 0000000000..4aaed68551 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css @@ -0,0 +1,48 @@ +/* Sticky turn bar: full-bleed ghost-active fill across the panel; title + + * metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */ + +.root { + position: sticky; + top: 0; + z-index: 1; + box-sizing: border-box; + width: 100%; + height: 44px; + background: var(--dsw-alias-button-ghost-active-fill); +} + +.inner { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + max-width: 880px; + height: 100%; + margin: 0 auto; + padding: 0 16px; +} + +.title { + flex: none; + font: var(--dsw-font-xs-strong-13); + color: var(--dsw-alias-label-primary); +} + +.columns { + flex: none; + display: flex; + align-items: center; + width: 320px; + gap: 12px; + /* Match cell padding-right: 8 so Time lines up with the trailing lane. */ + margin-right: 8px; +} + +.column { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx new file mode 100644 index 0000000000..ba54ed1c34 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx @@ -0,0 +1,30 @@ +// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels. + +import css from './TrajectoryTurnHeader.module.css' + +const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const + +export interface TrajectoryTurnHeaderProps { + /** 1-based turn index shown as `Turn N`. */ + turn: number +} + +/** + * Render the sticky turn header row. + * @param props.turn - turn index. + * @returns the sticky header element. + */ +export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) { + return ( + <div className={css.root}> + <div className={css.inner}> + <span className={css.title}>Turn {turn}</span> + <div className={css.columns} aria-hidden="true"> + {COLUMN_LABELS.map((label) => ( + <span key={label} className={css.column}>{label}</span> + ))} + </div> + </div> + </div> + ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 0ccb298801..45277eb628 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,30 +1,40 @@ -// TrajectoryView: P-I placeholder body for the trajectory tab — span stats -// header over a per-turn span list with node-count weights (no timing data -// exists yet; deviation ledger #3 defers real rendering to P-III). +// TrajectoryView: sticky Turn sections with Message/Step groups and step cells. import { useMemo } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { deriveSpans } from './spans.ts' -import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx' +import { TrajectoryCell } from './TrajectoryCell.tsx' +import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from './TrajectoryTurn.tsx' +import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' export function TrajectoryView({ useSession }: ConvViewProps) { const nodes = useSession((s) => s.nodes) - const spans = useMemo(() => deriveSpans(nodes), [nodes]) - if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div> + const partial = useSession((s) => s.partial) + const runningCalls = useSession((s) => s.runningCalls) + const turns = useMemo( + () => deriveTrajectoryLayout({ nodes, partial, runningCalls }), + [nodes, partial, runningCalls], + ) + if (turns.length === 0) { + return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div> + } return ( - <> - <TrajectoryStatsHeader useSession={useSession} /> - <div className={css.root}> - {spans.map((span) => ( - <div key={span.turn} className={css.row}> - <span className={css.turnTag}>turn {span.turn}</span> - <span className={css.meta}> - {span.steps} steps · {span.calls} calls · {span.nodes} nodes - </span> - </div> - ))} - </div> - </> + <div className={css.root}> + {turns.map((turn) => ( + <TrajectoryTurn key={turn.turn} turn={turn.turn}> + {turn.groups.flatMap((group) => [ + <TrajectoryGroupHeader + key={`${group.title}-h`} + title={group.title} + {...(group.description !== undefined ? { description: group.description } : {})} + />, + ...group.cells.map((cell) => ( + <TrajectoryCell key={cell.index} {...cell} /> + )), + ])} + </TrajectoryTurn> + ))} + </div> ) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 3979bfd91b..4a902fe9ff 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation'] /** * Client plugin body: register the trajectory and waterfall view tabs. The * registrations ride the slot service's effect wrapper (plugin unload - * removes both tabs); the span stats header renders inside each view body - * (the chrome attachment mechanism retired with the view ring). + * removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps + * the span stats header inside its body (chrome attachment retired). * @param ctx - client root context. */ export function apply(ctx: Context): void { diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts new file mode 100644 index 0000000000..03bfcb6893 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -0,0 +1,374 @@ +/** + * Trajectory list fold: expand assistant blocks, attach usage to Message, + * own-duration times, in-flight partial/runningCalls, and group descriptions. + */ +import type { + AssistantMessageNode, + ConversationSnapshot, + ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryCellProps } from './TrajectoryCell.tsx' + +/** One Message or Step group inside a turn. */ +export interface TrajectoryGroupModel { + title: string + description?: string + cells: readonly TrajectoryCellProps[] +} + +/** One sticky-turn section. */ +export interface TrajectoryTurnModel { + turn: number + groups: readonly TrajectoryGroupModel[] +} + +/** Snapshot slice the trajectory view folds. */ +export interface TrajectoryLayoutInput { + nodes: ConversationSnapshot['nodes'] + partial: ConversationSnapshot['partial'] + runningCalls: ConversationSnapshot['runningCalls'] +} + +interface UsageLike { + inputTokens?: number + outputTokens?: number + reasoningTokens?: number +} + +/** Cell plus absolute ms for group wall-span descriptions. */ +interface LaidCell { + cell: TrajectoryCellProps + absTime: number | null + toolName?: string + callId?: string +} + +/** + * Fold a snapshot into turn → Message/Step groups with expanded cells. + * @param input - nodes plus in-flight partial/runningCalls. + * @returns turns ordered by first appearance. + */ +export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { + const { nodes, partial, runningCalls } = input + const resultByCall = indexResults(nodes) + const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>() + let index = 0 + let prevAbsTime: number | null = null + + const bucket = (turn: number) => { + let entry = turns.get(turn) + if (entry === undefined) { + entry = { message: [], steps: new Map() } + turns.set(turn, entry) + } + return entry + } + + const pushMessage = (turn: number, laid: LaidCell) => { + bucket(turn).message.push(laid) + } + const pushStep = (turn: number, step: number, laid: LaidCell) => { + const steps = bucket(turn).steps + const list = steps.get(step) ?? [] + list.push(laid) + steps.set(step, list) + } + + for (const node of nodes) { + if (node.kind === 'user' || node.kind === 'steering') { + const turn = node.kind === 'steering' ? node.turn : 0 + pushMessage(turn, { + absTime: finiteTime(node.time), + cell: { + index: ++index, kind: 'user', text: summarizeContent(node.content), + timeSeconds: 0, + }, + }) + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'assistant') { + const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall) + for (const laid of laidList) { + if (node.step > 0) pushStep(node.turn, node.step, laid) + else pushMessage(node.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'tool-result') { + if (!callEmittedInAssistant(nodes, node.callId)) { + const toolName = node.call?.name + pushStep(0, 1, { + absTime: finiteTime(node.callTime ?? node.time), + ...(toolName !== undefined ? { toolName } : {}), + callId: node.callId, + cell: { + index: ++index, + kind: 'tool', + text: node.call !== null + ? summarizeCall(node.call.name, node.call.argsRaw) + : summarizeResult(node), + timeSeconds: durationSeconds(node.time, node.callTime), + }, + }) + } + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + } + } + + if (partial !== null) { + const fake: AssistantMessageNode = { + kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0, + turn: partial.turn, step: partial.step, blocks: partial.blocks, + } + const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true }) + for (const laid of laidList) { + if (partial.step > 0) pushStep(partial.turn, partial.step, laid) + else pushMessage(partial.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + } + + const seenCalls = collectCallIds(turns) + for (const call of runningCalls) { + if (seenCalls.has(call.callId)) continue + pushStep(call.turn, call.step > 0 ? call.step : 1, { + absTime: null, + toolName: call.name, + callId: call.callId, + cell: { + index: ++index, + kind: 'tool', + text: summarizeCall(call.name, call.argsRaw), + timeSeconds: null, + }, + }) + } + + const prologue = turns.get(0) + if (prologue !== undefined) { + turns.delete(0) + const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({ + message: [], + steps: new Map(), + }) + const first = turns.get(1) ?? emptyTurn() + first.message = [...prologue.message, ...first.message] + for (const [step, cells] of prologue.steps) { + const existing = first.steps.get(step) ?? [] + first.steps.set(step, [...cells, ...existing]) + } + turns.set(1, first) + } + + return [...turns.entries()] + .sort(([a], [b]) => a - b) + .map(([turn, entry]) => toTurnModel(turn, entry)) +} + +function toTurnModel( + turn: number, + entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> }, +): TrajectoryTurnModel { + const groups: TrajectoryGroupModel[] = [] + if (entry.message.length > 0) { + const description = groupDescription(entry.message) + groups.push({ + title: 'Message', + ...(description !== undefined ? { description } : {}), + cells: entry.message.map(l => l.cell), + }) + } + for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) { + const laid = entry.steps.get(step) ?? [] + const description = groupDescription(laid) + groups.push({ + title: `Step ${step}`, + ...(description !== undefined ? { description } : {}), + cells: laid.map(l => l.cell), + }) + } + return { turn, groups } +} + +/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ +function groupDescription(laid: readonly LaidCell[]): string | undefined { + const parts: string[] = [] + // Tool rows contribute start (absTime) and end (start + own duration) so a + // single Tool cell still spans call→result for the group wall clock. + const times: number[] = [] + for (const l of laid) { + if (l.absTime === null || !Number.isFinite(l.absTime)) continue + times.push(l.absTime) + if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) { + times.push(l.absTime + l.cell.timeSeconds * 1000) + } + } + if (times.length >= 2) { + const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000) + if (span !== undefined) parts.push(span) + } else if (times.length === 1) { + const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds + const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined + if (span !== undefined) parts.push(span) + } + const tools = new Map<string, number>() + for (const l of laid) { + if (l.toolName === undefined || l.cell.kind !== 'tool') continue + tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1) + } + for (const [name, count] of tools) { + parts.push(count > 1 ? `${name}×${count}` : name) + } + return parts.length === 0 ? undefined : parts.join(' ') +} + +function formatGroupDuration(seconds: number): string | undefined { + if (!Number.isFinite(seconds)) return undefined + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `${rounded}s` + return `${rounded.toFixed(1)}s` +} + +/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ +function durationSeconds(later: number, earlier: number | null): number | null { + if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null + return Math.max(0, (later - earlier) / 1000) +} + +/** Epoch-ms usable as an absolute time, else null. */ +function finiteTime(time: number): number | null { + return Number.isFinite(time) ? time : null +} + +function expandAssistant( + node: AssistantMessageNode, + startIndex: number, + prevAbsTime: number | null, + results: Map<string, ToolResultNode>, + opts?: { streaming?: boolean }, +): LaidCell[] { + const out: LaidCell[] = [] + let index = startIndex - 1 + const usage = node.usage as UsageLike | undefined + const streaming = opts?.streaming === true + const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime) + const nodeAbs = streaming ? null : finiteTime(node.time) + let usageAttached = false + + for (const block of node.blocks) { + // Reasoning blocks are skipped: no block-level clock, so no Think cell. + if (block.kind === 'reasoning') continue + if (block.kind === 'text') { + if (block.text === '' && streaming) continue + const cell: TrajectoryCellProps = { + index: ++index, kind: 'message', text: summarizeText(block.text), + timeSeconds: messageDuration, + } + if (!usageAttached && usage !== undefined) { + if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.outputTokens !== undefined) cell.output = usage.outputTokens + if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens + usageAttached = true + } + out.push({ absTime: nodeAbs, cell }) + continue + } + if (block.kind === 'tool-call') { + const result = results.get(block.callId) + const toolDuration = streaming || result === undefined + ? null + : durationSeconds(result.time, result.callTime) + const callAbs = streaming + ? null + : (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime) + ? result.callTime + : nodeAbs) + out.push({ + absTime: callAbs, + toolName: block.name, + callId: block.callId, + cell: { + index: ++index, kind: 'tool', + text: summarizeCall(block.name, block.argsRaw), + timeSeconds: toolDuration, + }, + }) + } + } + + if (out.length === 0 && !streaming) { + out.push({ + absTime: nodeAbs, + cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration }, + }) + } + return out +} + +function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> { + const map = new Map<string, ToolResultNode>() + for (const node of nodes) { + if (node.kind === 'tool-result') map.set(node.callId, node) + } + return map +} + +function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean { + for (const node of nodes) { + if (node.kind !== 'assistant') continue + if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true + } + return false +} + +function collectCallIds( + turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>, +): Set<string> { + const ids = new Set<string>() + for (const entry of turns.values()) { + for (const laid of entry.message) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + for (const list of entry.steps.values()) { + for (const laid of list) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + } + } + return ids +} + +function summarizeCall(name: string, argsRaw: string): string { + const args = argsRaw.replace(/\s+/g, ' ').trim() + if (args === '') return name + const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args + return `${name} · ${clipped}` +} + +function summarizeResult(node: ToolResultNode): string { + if (node.isError) { + return node.error?.code ?? 'error' + } + for (const block of node.content) { + if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { + return summarizeText(block.text) + } + } + return node.call?.name ?? node.callId +} + +function summarizeContent(content: readonly { type: string; text?: string }[]): string { + for (const block of content) { + if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) + } + return '' +} + +function summarizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 951a5e2705..d3089b3568 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,25 +1,34 @@ +/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge; + * cell content width is capped on the turn body (max 880). */ .root { - padding: 16px; overflow-y: auto; + height: 100%; + min-height: 0; + width: 100%; + box-sizing: border-box; color: var(--dsw-alias-label-primary); - font-size: 13px; + background: var(--dsw-specific-sidebar-fill); } .empty { + padding: 16px; color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); } +/* Waterfall placeholder rows (shared module). */ .row { display: flex; align-items: center; gap: 8px; - padding: 4px 0; + padding: 4px 16px; } .turnTag { flex: none; width: 64px; color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); } .bar { @@ -29,9 +38,10 @@ } .barCalls { - background: var(--dsw-alias-brand-primary); + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); } .meta { color: var(--dsw-alias-label-caption); + font: var(--dsw-font-xs-13); } diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.spec.tsx new file mode 100644 index 0000000000..d9c9004622 --- /dev/null +++ b/packages/client/ui-trajectory/tests/cell.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +/** + * TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message + * metric columns, own-duration formatting, and selected ring. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + formatElapsedSeconds, + TrajectoryCell, + type TrajectoryCellKind, +} from '../src/client/TrajectoryCell.tsx' + +afterEach(cleanup) + +describe('formatElapsedSeconds', () => { + it('formats known durations and uses an em dash when absent', () => { + expect(formatElapsedSeconds(null)).toBe('—') + expect(formatElapsedSeconds(235)).toBe('+235s') + expect(formatElapsedSeconds(235.0)).toBe('+235s') + expect(formatElapsedSeconds(235.2)).toBe('+235.2s') + expect(formatElapsedSeconds(235.25)).toBe('+235.3s') + expect(formatElapsedSeconds(0)).toBe('+0s') + expect(formatElapsedSeconds(Number.NaN)).toBe('—') + }) +}) + +describe('TrajectoryCell', () => { + it('renders index, kind tag, text, and time for a Tool row', () => { + render( + <TrajectoryCell + index={6} + kind="tool" + text="bash · Read src/index.ts" + timeSeconds={5} + />, + ) + expect(screen.getByText('#6')).toBeTruthy() + expect(screen.getByText('Tool')).toBeTruthy() + expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() + expect(screen.getByText('+5s')).toBeTruthy() + }) + + it('Message rows expose Input / Output / Think metric columns before time', () => { + const { container } = render( + <TrajectoryCell + index={3} + kind="message" + text="Let me now read the actual source files to understa..." + timeSeconds={235.2} + input={136} + output={381} + think={155} + />, + ) + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('136')).toBeTruthy() + expect(screen.getByText('381')).toBeTruthy() + expect(screen.getByText('155')).toBeTruthy() + expect(screen.getByText('+235.2s')).toBeTruthy() + const texts = [...container.querySelectorAll('span')].map((el) => el.textContent) + expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) + expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155')) + expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s')) + }) + + it('selected marks the row for the brand-primary inset ring', () => { + const { container } = render( + <TrajectoryCell index={15} kind="message" text="pictur..." timeSeconds={123.6} selected />, + ) + expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true') + }) + + it.each([ + ['user', 'User'], + ['tool', 'Tool'], + ] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => { + const { container } = render( + <TrajectoryCell index={1} kind={kind} text="summary" timeSeconds={kind === 'user' ? 0 : null} input={1} output={2} think={3} />, + ) + expect(screen.getByText(label)).toBeTruthy() + expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind) + expect(screen.queryByText('1')).toBeNull() + expect(screen.queryByText('2')).toBeNull() + expect(screen.queryByText('3')).toBeNull() + }) +}) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx new file mode 100644 index 0000000000..4dc395221d --- /dev/null +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +/** + * Trajectory turn chrome and layout fold: expand blocks, usage on Message, + * tool own-duration, group wall-span descriptions, in-flight rows. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' +import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' +import { deriveTrajectoryLayout } from '../src/client/layout.ts' + +afterEach(cleanup) + +describe('TrajectoryTurnHeader', () => { + it('renders Turn N and the four metric column labels', () => { + render(<TrajectoryTurnHeader turn={1} />) + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Input')).toBeTruthy() + expect(screen.getByText('Output')).toBeTruthy() + expect(screen.getByText('Think')).toBeTruthy() + expect(screen.getByText('Time')).toBeTruthy() + }) +}) + +describe('TrajectoryGroupHeader', () => { + it('renders title and optional description', () => { + render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />) + expect(screen.getByText('Step 1')).toBeTruthy() + expect(screen.getByText('2.2s skill')).toBeTruthy() + }) + + it('omits the description node when absent', () => { + const { container } = render(<TrajectoryGroupHeader title="Message" />) + expect(screen.getByText('Message')).toBeTruthy() + expect(container.querySelectorAll('span')).toHaveLength(1) + }) +}) + +describe('TrajectoryTurn', () => { + it('wraps a sticky header and body children', () => { + render( + <TrajectoryTurn turn={3}> + <TrajectoryGroupHeader title="Message" description="49s" /> + </TrajectoryTurn>, + ) + expect(screen.getByText('Turn 3')).toBeTruthy() + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('49s')).toBeTruthy() + }) +}) + +describe('deriveTrajectoryLayout', () => { + it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null }, + { + kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: 'thinking…' }, + { kind: 'text', text: 'I will run bash' }, + { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' }, + ], + usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 }, + }, + { + kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1', + call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, + content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns).toHaveLength(1) + expect(turns[0]?.turn).toBe(1) + const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind)) + expect(kinds).toEqual(['user', 'message', 'tool']) + const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') + expect(message).toMatchObject({ + input: 10, output: 20, think: 5, timeSeconds: 5, + }) + const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool') + expect(tool?.text).toBe('bash · {"command":"ls"}') + expect(tool?.timeSeconds).toBe(1.3) + }) + + it('adds runningCalls not already present and leaves their time blank', () => { + const turns = deriveTrajectoryLayout({ + nodes: [] as unknown as ConversationSnapshot['nodes'], + partial: null, + runningCalls: [{ + callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', + turn: 1, step: 2, time: 9_000, callView: null, + }], + }) + expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2']) + expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({ + kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null, + }) + }) + + it('omits duration when node times are missing instead of rendering NaN', () => { + const nodes = [ + { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null }, + { + kind: 'assistant', seq: 2, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: '…' }, + { kind: 'text', text: 'ok' }, + ], + usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? [] + expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull() + expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined() + }) + + it('builds a wall-span step description with a tool histogram', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, + blocks: [ + { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' }, + { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' }, + ], + }, + { + kind: 'tool-result', seq: 2, time: 2_500, callId: 'a', + call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'tool-result', seq: 3, time: 4_000, callId: 'b', + call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, + content: [], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index a3ac84738e..4818e67db5 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -3,9 +3,9 @@ * View registration acceptance on the real framework stack: the plugin fiber * registers trajectory/waterfall into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab - * projection apply uses) without collapsing chat, the span stats header - * renders inside both view bodies, and fiber disposal removes both tabs. - * Span derivation edge cases ride along. + * projection apply uses) without collapsing chat, trajectory renders the + * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and + * fiber disposal removes both tabs. Span derivation edge cases ride along. */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -36,19 +36,26 @@ afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { - localStorage.clear() + // Node 22+ exposes an experimental localStorage global that is undefined + // without --localstorage-file; only clear when a real Storage is present. + if (typeof localStorage !== 'undefined') localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ const NODES = [ - { kind: 'user', seq: 1, content: [], source: null }, - { kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] }, - { kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null }, - { kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] }, + { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, + { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] }, + { + kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null, + content: [], isError: false, callView: null, resultView: null, + }, + { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] }, ] as unknown as ConversationSnapshot['nodes'] function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes }) + const store = createSnapshotStore({ + nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], + }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> } } @@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] { /** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({ + const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, + partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot> const chat = createChatStore().create() @@ -158,17 +166,20 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => { + it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => { const b = await bench() mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - // In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call. - expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy() - expect(screen.getByText('turn 0')).toBeTruthy() - expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy() + // Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body. + expect(screen.queryByText(/turns ·/)).toBeNull() + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Turn 2')).toBeTruthy() + expect(screen.getAllByText('Message').length).toBeGreaterThan(0) + expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Input').length).toBeGreaterThan(0) expect(screen.queryByTestId('chat-body')).toBeNull() }) From a4b4a4c53df965982234a3b7f8b6e1cfb0b81c3f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 13:36:27 +0800 Subject: [PATCH 071/113] fix: type check --- .../tests/chat-toolview-slot.spec.tsx | 3 ++- .../ui-conversation/tests/skeleton.spec.tsx | 23 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 69cb2cfa09..5d2b3408a2 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -31,8 +31,9 @@ beforeEach(() => { }) const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({ - kind: 'tool-result', seq, callId, + kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: args }, + callTime: seq * 1_000 - 500, content: [], isError: false, callView: null, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index c923c19269..55cb46be2c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -33,8 +33,27 @@ beforeEach(() => { /** Minimal conversation snapshot slice the skeleton reads. */ interface FakeSnapshot { - nodes: readonly { kind: string; callId?: string; call?: { name: string; argsRaw: string } | null; content?: readonly { type: string; text?: string }[]; isError?: boolean }[] - runningCalls: readonly { callId: string; name: string; argsRaw: string }[] + nodes: readonly { + kind: string + seq?: number + time?: number + callId?: string + call?: { name: string; argsRaw: string } | null + callTime?: number | null + content?: readonly { type: string; text?: string }[] + isError?: boolean + callView?: null + resultView?: null + }[] + runningCalls: readonly { + callId: string + name: string + argsRaw: string + turn?: number + step?: number + time?: number + callView?: null + }[] running: boolean removed: boolean promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null From d613fdb073b8235a63ddb8087bcc692daa7942ea Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 13:38:36 +0800 Subject: [PATCH 072/113] =?UTF-8?q?fix(agent-loop):=20third=20review=20pas?= =?UTF-8?q?s=20=E2=80=94=20disposal=20discard=20ordering,=20flush=20guard,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address a fresh-eye review of the disposal/injection fixes: - disposal now snapshots, clears, and marks disposed BEFORE emitting agent/inbox/discard (mirroring cancel's snapshot→clear→emit), so a re-entrant send/cancel from a discard listener throws 'disposed' or finds an empty inbox instead of leaking or double-discarding an id. The discard is unconditional (even on unpublished setup-rollback) to match send's unconditional enqueue, keeping every id balanced. - restore the turnRecorded guard on the idle-injection flush: a turn/start rejected pre-commit (append reentrancy / internal-dispatch veto) records nothing and owes no flush; the previous unconditional flush emitted a phantom-turn agent/error. The isTurnOpen/turnRecorded branches are reachable (reentrant inject from a session/event listener) and now covered by a regression test rather than v8-ignored. - rewrite the agent/inbox/discard event JSDoc to enumerate all three emitters (cancel, terminal turn-stop, disposal) — every enqueued id gets exactly one terminal dequeue-or-discard. Per-file coverage stays 100%. --- docs/cordis-catalog/events.md | 42 ++++++----- docs/event-producer-consumer.md | 26 +++---- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/src/agent.ts | 69 ++++++++++--------- packages/core/agent-loop/tests/agent.spec.ts | 32 +++++++++ packages/core/agent/src/types.ts | 14 ++-- 6 files changed, 117 insertions(+), 70 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 56c927e0db..05aea0ea7e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:507`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -121,15 +121,19 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t ### `agent/inbox/discard` — emit -`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them. Fires once per effective clearing call with every discarded item, after `agent/cancel-requested` and before the abort. +Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item. ```ts cordis-catalog /** - * `cancel()` (without `keepInbox`) dropped pending inbox items without - * delivering them. Fires once per effective clearing call with every - * discarded item, after `agent/cancel-requested` and before the abort. - * @param agent - the agent whose inbox was cleared. - * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ @@ -138,7 +142,7 @@ Source: [`packages/core/agent/src/types.ts:335`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -184,7 +188,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +211,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:384`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +238,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +263,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:414`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:418`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +289,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:468`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +315,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:429`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:433`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +337,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -376,7 +380,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:445`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +402,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:479`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:483`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +424,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:490`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:494`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f0469118ba..94b204ad43 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:507`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:335`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:325`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:384`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:468`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:429`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:418`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:433`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:372`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:479`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:490`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:445`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:483`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:494`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 150c4880b3..1438096347 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -892,8 +892,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/discard', mode: 'emit', signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void', - jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', }, { name: 'agent/inbox/enqueue', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 9bc24de5da..b0a20a59e1 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -276,34 +276,40 @@ export class ReactLoopAgent extends Agent { } // No turn open: wrap the injection in a one-shot turn so every event stays // turn-enclosed (the durability/replay boundary is the turn). The payload is - // validated above, so both appends commit together; the finally still owes - // a turn/end (the turn-enclosure invariant) even if a post-commit observer - // throws after turn/start. + // validated above, but `Session.append` can still reject a turn/start + // pre-commit (append re-entrancy from a session/event listener, or an + // internal-dispatch veto), so the finally owes a turn/end only when + // turn/start actually committed. const turn = lastTurnNumber(this.session) + 1 try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) this.session.append('user/message', accepted, { surfaceOp: 'append' }) } finally { - // Close the turn if turn/start committed. With the payload validated up - // front both appends commit together, so the turn is always open here; - // the guard remains the turn-enclosure backstop. - /* v8 ignore next -- unopened turn is unreachable after up-front validation; kept as the enclosure backstop. */ + // Close the turn if turn/start made it into the log. A pre-commit veto + // must escape rather than being mistaken for a committed turn/end. if (isTurnOpen(this.session)) { this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // Flush the one-shot turn through the store (the carrier owner), never a - // raw parallel. Keep inject() synchronous: report checkpoint failures live - // instead of rejecting the caller, and track the task so disposal drains it. - const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { - const rendered = errorChain(error) - const err = error instanceof Error ? error : new Error(rendered) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) - agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) - }) - this.pendingIdleFlushes.add(flush) - // Retire on either settlement path. - const retire = (): void => { this.pendingIdleFlushes.delete(flush) } - void flush.then(retire, retire) + // Checkpoint only an accepted one-shot turn: a turn/start rejected + // pre-commit recorded nothing, so it owes no flush (and a spurious flush + // would emit a phantom-turn agent/error). The payload is validated up + // front, so a committed turn/start is always followed by its user/message. + const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) + // Keep inject() synchronous: report checkpoint failures live instead of + // rejecting the caller, and track the task so disposal still drains it. + if (turnRecorded) { + // Through the store's flush (the carrier owner), never a raw parallel. + const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { + const rendered = errorChain(error) + const err = error instanceof Error ? error : new Error(rendered) + this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) + agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) + }) + this.pendingIdleFlushes.add(flush) + // Retire on either settlement path. + const retire = (): void => { this.pendingIdleFlushes.delete(flush) } + void flush.then(retire, retire) + } } } @@ -438,20 +444,21 @@ export class ReactLoopAgent extends Agent { */ private [stopDriver](): Promise<void> | void { if (this._status !== 'disposed') { - // Discard any still-pending inbox items before disposal so every enqueued - // id gets a terminal lifecycle event; a disposed agent never dequeues - // them. Emitted while still published (before the status flip below), and - // only when there is a public lifecycle to observe it. - if (this.published) { - const discarded = this.#inbox.pending() - if (discarded.length > 0) { - const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) - agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) - } - } + // Snapshot any still-pending inbox items, then CLEAR and mark disposed + // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit + // order so a re-entrant send()/cancel() from a discard listener throws + // `disposed` (or finds an empty inbox) instead of leaking or double- + // discarding an id. `send()` emits enqueue unconditionally, so the discard + // is unconditional too (even on an unpublished rollback) to keep every + // enqueued id matched. + const discarded = this.#inbox.pending() this.#inbox.clear() this._status = 'disposed' this.resolveDisposed() + if (discarded.length > 0) { + const items = discarded.map(({ message, steering }) => agentMessage(message, steering)) + agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items) + } // Release whenIdle waiters BEFORE the (guarded) event emit — they are // internal state that must settle even if a listener throws below. Each // waiter chains `done`, so it resolves only once the loop actually exits. diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 3b8942d27c..c22919e066 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -216,6 +216,38 @@ describe('Agent', () => { expect(flushes).toBe(0) // nothing was appended, so no checkpoint }) + it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + let flushes = 0 + ctx.on('session/flush', () => { flushes += 1 }) + // Injecting from inside a session/event listener re-enters Session.append, + // which rejects pre-commit — so turn/start never commits. The finally sees + // no open turn (closes nothing) and no recorded turn (no checkpoint), and + // the reentrant throw is contained by Session's post-commit dispatch. + // Fire on turn/end: at that instant the outer one-shot turn is closed (no + // turn open), so the reentrant inject takes the idle one-shot-turn path and + // its turn/start append re-enters Session and is rejected pre-commit. + let reentered = false + ctx.on('session/event', (_s, event) => { + if (!reentered && event.type === 'turn/end') { + reentered = true + agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } }) + } + }) + + agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } }) + // The outer injection's own one-shot turn is balanced; the reentrant one + // opened no turn (its turn/start was rejected pre-commit). + const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + expect(turnStarts).toHaveLength(1) + const injected = agent.session.events.filter(e => e.type === 'user/message') + expect(injected).toHaveLength(1) // the reentrant user/message never committed + await new Promise(r => setTimeout(r, 10)) + expect(flushes).toBe(1) // only the outer accepted turn checkpointed + }) + it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d542c39810..57a71c7772 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -334,11 +334,15 @@ declare module 'cordis' { */ 'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void /** - * `cancel()` (without `keepInbox`) dropped pending inbox items without - * delivering them. Fires once per effective clearing call with every - * discarded item, after `agent/cancel-requested` and before the abort. - * @param agent - the agent whose inbox was cleared. - * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending. + * Pending inbox items were dropped without delivering them, so every + * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR + * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after + * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` + * dropping pending steering (in-turn and on the post-turn late-steering + * drain); and disposal of any still-pending items (before + * `agent/status('disposed')`). Fires once per drop with every dropped item. + * @param agent - the agent whose inbox items were dropped. + * @param messages - the discarded messages in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ From d6d50deb248d6f9076f6c6067f2fb6023797e69a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:52:25 +0800 Subject: [PATCH 073/113] refactor(agent): expose resolved input acceptance --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 4 +- ...ied-send-and-coalesced-user-messages.zh.md | 4 +- ...7-24-intent-named-agent-delivery.i18n.yaml | 4 +- .../2026-07-24-intent-named-agent-delivery.md | 20 +++++---- ...26-07-24-intent-named-agent-delivery.zh.md | 20 +++++---- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 43 +++++++++--------- docs/core-data-structures/core.md | 44 +++++++++++++++--- docs/event-producer-consumer.md | 36 +++++++-------- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../time-context/tests/time-context.spec.ts | 1 + .../tests/workspace-context.spec.ts | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 8 +++- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 28 ++++-------- packages/core/agent-loop/tests/agent.spec.ts | 34 ++++++++++++++ packages/core/agent/README.md | 5 ++- packages/core/agent/src/types.ts | 45 ++++++++++++++++--- packages/core/agent/tests/agent.spec.ts | 14 +++++- .../command-goal/tests/command-goal.spec.ts | 1 + packages/goal/goal/tests/goal.spec.ts | 1 + .../goal/tool-goal/tests/tool-goal.spec.ts | 1 + packages/pty/pty-local/tests/index.spec.ts | 6 +-- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 1 + .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- packages/tasks/tasks/tests/tasks.spec.ts | 1 + packages/ui/tui/tests/harness.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 12 ++--- scripts/type-equiv.manifest.json | 5 +++ 35 files changed, 244 insertions(+), 120 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index c5c9c83dc8..83f3b86de8 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 9f6b29b4e1d90d75dbb88ab770c7393cdb859627 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4469d211da23a24f61017770b29bc983a2abb5f1 +2026-07-22-unified-send-and-coalesced-user-messages.md: f9196b2170bdcdc45325abc1666d0c1545d475ac +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: b984d0706a6758e37df312d0b59bba3afb602258 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 9f6b29b4e1..f9196b2170 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -12,7 +12,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One private mechanism, four public intents.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one private (`target` × `wakeup`) delivery mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes no routing fields or abstract base; the [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. +**One acceptance mechanism, four intent helpers.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface also exposes that mechanism as `acceptInput(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. **inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. @@ -43,4 +43,4 @@ Internally, `wakeup` is the “should the model run” signal, so the inbox dist - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public interface and private routing placement. +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public helpers and fully resolved acceptance interface. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 4469d211da..b984d0706a 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -12,7 +12,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一个私有机制,四种公开意图。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个私有的(`target` × `wakeup`)投递机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口不暴露路由字段或抽象基类;取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 +**一种接受机制,四种意图辅助方法。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口还将该机制暴露为 `acceptInput(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 **inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 @@ -43,4 +43,4 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 - [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开接口和私有路由的归属位置。 +- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开辅助方法以及接受完全解析输入的接口。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml index a6220691f1..69ec4e0895 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-intent-named-agent-delivery.md: 62a05ed7601ca3dd2f05fae609341ab3fa37671a -2026-07-24-intent-named-agent-delivery.zh.md: e559b106ca54f901a18f2936142770d6306ef757 +2026-07-24-intent-named-agent-delivery.md: 54ea6353a516830795d51c395706241a4570260d +2026-07-24-intent-named-agent-delivery.zh.md: 0d903a0d5dd383a154ade02ee8bed607d1f986b1 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md index 62a05ed760..54ea6353a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -6,13 +6,13 @@ English | [中文](2026-07-24-intent-named-agent-delivery.zh.md) ## Problem -A public `send(content, { target, wakeup, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. The mechanism is useful inside the concrete driver, but exposing it gives plugins implementation knowledge without leverage. +A configurable `send(content, { target?, wakeup?, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. Most callers have one semantic intent, while some adapters already possess exact routing facts and should not have to reverse-map them into a helper name. Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter. ## Decision -`Agent` is a structural interface with four intent-named delivery methods: +`Agent` is a structural interface with four intent-named delivery helpers: - `send()` queues an ordinary turn and wakes the driver. - `queue()` queues an ordinary turn without waking an idle driver. @@ -21,15 +21,17 @@ Sharing helper implementations through an abstract `Agent` class also makes the `send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. -`ReactLoopAgent` resolves each public call into one module-private `ResolvedAgentInput` and passes it to native-private `#acceptInput`. Every internal field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. Injection resolves contexts to the empty tuple. The private name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. +`Agent` also exposes `acceptInput(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. -The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop`. It is not exported, protected, or represented by a base class. With one concrete adapter, a subclass delivery seam would be hypothetical; callers and tests use the same public `Agent` interface. +The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface. ## Alternatives considered -**Keep a public configurable primitive.** Mandatory routing arguments remove defaulting mistakes but still require every caller to learn the matrix and allow invalid intent combinations such as attached contexts on injection. The concrete loop needs that flexibility; ordinary plugins do not. +**Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state. -**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** Visibility belongs in the type and runtime boundary, not a warning in a public name. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work. +**Use configurable `send` as the primitive.** Even mandatory routing arguments would make the common method carry advanced concerns. Keeping `send` semantic preserves its simple defaulted call shape; the separate discriminated input type rejects attached contexts on injection. + +**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** A public method must not describe itself as internal. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work; `acceptInput` names the synchronous guarantee instead. **Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias. @@ -37,13 +39,13 @@ The target/wakeup matrix remains an implementation mechanism in `dsh-agent-loop` ## Verification -Focused agent-loop coverage exercises waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes and rejects routing fields on `SendOptions` and contexts on `InjectOptions`. The keyless Cordis inspection snapshot pins the model-facing interface without a configurable delivery primitive or abstract-class implementation. +Focused agent-loop coverage exercises direct fully resolved acceptance, waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes, requires every `ResolvedAgentInput` field, requires empty contexts on its injection variant, and keeps routing fields out of `SendOptions`. The keyless Cordis inspection snapshot pins the structural interface without an abstract-class implementation. ## Consequences -Callers choose one verb instead of encoding two routing axes. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface restores simple adapters and fakes. Adding a delivery intent now requires an explicit public name and mapping rather than another matrix combination. +Ordinary callers choose one verb instead of encoding two routing axes; advanced callers may submit the exact discriminated route. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface preserves simple adapters and fakes. Adding a common delivery intent still requires an explicit public helper and mapping rather than another optional matrix combination. -Four short public methods duplicate a small amount of argument resolution inside the concrete adapter. That duplication is deliberate locality: defaults and routing stay beside the only implementation that owns them, and no generator support is needed merely to expose a class-shaped `Agent`. +The advanced method adds interface surface and requires structural fakes to implement it. In return, resolved routing has one typed representation, while helper defaults and mappings stay beside the only implementation that owns them. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md index e559b106ca..0d903a0d5d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -公开的 `send(content, { target, wakeup, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。该机制在具体驱动器内部很有用,但将其公开只会让插件背负实现知识,却得不到相应收益。 +可配置的 `send(content, { target?, wakeup?, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。大多数调用方只有一种语义意图,而有些适配器已经持有确切的路由信息,不应再被迫将这些信息反向映射为某个辅助方法名称。 通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。 ## 决策 -`Agent` 是一个结构化接口,提供四种按意图命名的投递方法: +`Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法: - `send()` 将一个普通轮次入队并唤醒驱动器。 - `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 @@ -21,15 +21,17 @@ Status: implemented `send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 -`ReactLoopAgent` 把每次公开调用解析为一个模块私有的 `ResolvedAgentInput`,再将其传给原生私有的 `#acceptInput`。每个内部字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。注入会把上下文解析为空元组。这个私有名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 +`Agent` 还公开 `acceptInput(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。这个名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 -target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出,不是 protected 成员,也不由基类表示。只有一个具体适配器时,子类投递 seam 只是假想的;调用方和测试使用同一个公开 `Agent` 接口。 +结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。 ## 考虑过的替代方案 -**保留公开的可配置原语。** 强制提供路由参数可以消除默认值错误,但仍会要求每个调用方理解矩阵,也允许出现无效的意图组合,例如为注入附加上下文。具体循环需要这种灵活性;普通插件不需要。 +**让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。 -**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 可见性应由类型和运行时边界表达,而不是在公开名称中加入警告。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作。 +**使用可配置的 `send` 作为原语。** 即使强制提供所有路由参数,也会让这个常用方法承载高级用法的复杂性。让 `send` 只表达语义意图,可以保留其带默认值的简单调用形式;单独的可辨识输入类型则会拒绝为注入附加上下文。 + +**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 公开方法不应在名称中把自己称为内部方法。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作;`acceptInput` 描述的则是同步边界所保证的事实。 **保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名。 @@ -37,13 +39,13 @@ target/wakeup 矩阵仍是 `dsh-agent-loop` 中的实现机制。它不会导出 ## 验证 -聚焦的 agent-loop 覆盖率测试通过公开方法覆盖唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,并拒绝 `SendOptions` 上的路由字段和 `InjectOptions` 上的上下文。无密钥的 Cordis 检查快照固定了面向模型的接口,其中既没有可配置的投递原语,也没有抽象类实现。 +聚焦的 agent-loop 覆盖率测试通过公开方法覆盖直接接受完全解析的输入、唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,要求提供 `ResolvedAgentInput` 的每个字段,要求其注入变体的上下文为空,并确保 `SendOptions` 不包含路由字段。无密钥的 Cordis 检查快照固定了不采用抽象类实现的结构化接口。 ## 后果 -调用方选择一个动词即可,无需编码两条路由轴。具体循环保留一条接受路径和一个归属边界,而结构化接口重新支持简单的适配器和测试替身。新增一种投递意图时,需要显式给出公开名称和映射,而不是再增加一种矩阵组合。 +普通调用方选择一个动词即可,无需编码两条路由轴;高级调用方则可提交经过判别的精确路由。具体循环保留一条接受路径和一个归属边界,而结构化接口保留了对简单适配器和测试替身的支持。新增一种常见投递意图时,仍需要显式提供公开辅助方法及其映射,而不是再增加一种可选的矩阵组合。 -四个简短的公开方法会在具体适配器中重复少量参数解析。这项重复是为了让实现保持局部:默认值和路由都留在拥有它们的唯一实现旁边,无需仅为了暴露类形态的 `Agent` 而增加生成器支持。 +这个高级方法会扩大接口范围,并要求结构化测试替身实现它。作为回报,解析后的路由只有一种类型化表示,而辅助方法的默认值和映射仍留在拥有它们的唯一实现旁边。 ## 相关 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 7463516dba..3eec5bed79 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: edb42170fb508e522f2f26fd576902cc284e9eb4 -architecture.zh.md: e60fc2b2c61f139bbca59a3e9daa4703b2ce5976 +architecture.md: 02b5d854f87e06db6b90d6402ea630e196bc5a23 +architecture.zh.md: 57adb6a2959ee9336364b403f736f914b72b42f4 diff --git a/docs/architecture.md b/docs/architecture.md index edb42170fb..02b5d854f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ Session events are turn-enclosed; reload closes an interrupted tail with a synth ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent-named `send()`, `queue()`, `steer()`, and `inject()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)); `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins normally use intent-named `send()`, `queue()`, `steer()`, and `inject()`; callers with fully resolved routing may use `acceptInput()` with every field mandatory ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index e60fc2b2c6..57adb6a295 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,7 +131,7 @@ forever: ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md));`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件通常使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`;调用方若已持有完全解析的路由信息,可以使用 `acceptInput()`,其中每个字段均为必填项([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 ### Agent 作用域 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index fa8494b14c..d5746ad12b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:494`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -138,19 +138,20 @@ Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/t Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** * A detached, frozen item entered the agent's inbox (queued or steering * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record - * is the eventual `user/message`/`steering/message`. Injection - * through `agent.inject()` bypasses the FIFOs and does not emit this. + * is the eventual `user/message`/`steering/message`. Injection through + * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -161,7 +162,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -184,7 +185,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:413`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -207,7 +208,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:344`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:375`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -234,7 +235,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:391`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -259,7 +260,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -285,7 +286,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:459`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -311,7 +312,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -333,7 +334,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -353,7 +354,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking deliver Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -376,7 +377,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:401`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:432`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -398,7 +399,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:439`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:470`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -420,7 +421,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:481`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 85689a89d6..82c7ebc24d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -388,6 +388,26 @@ interface InjectOptions { } ``` +The advanced acceptance form makes every default explicit and rules out attached contexts on injection: + +```ts type-equiv +/** + * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * helpers, this form applies no defaults: callers provide content, source, + * contexts, metadata (including explicit `undefined`), target, and wakeup. + * The union excludes attached contexts from non-waking next-step injection. + */ +type ResolvedAgentInput = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) +``` + FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events: ```ts type-equiv @@ -403,16 +423,17 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value `send`, `queue`, or `steer` returned to the caller, stable across - * this message's enqueue, dequeue, and discard events. Source defaults are - * already applied, so these are the exact values the item was accepted with. + * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * stable across this message's enqueue, dequeue, and discard events. Source + * defaults, when applicable, are already applied, so these are the exact values + * the item was accepted with. * `steering` is true for an item drained between steps; otherwise it is claimed * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ interface AgentMessage { - /** The id returned by the accepting `send`, `queue`, or `steer` call. */ + /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -443,7 +464,7 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -The structural `Agent` interface exposes four delivery intents. The concrete driver resolves them into a private routing mechanism rather than exporting the target/wakeup matrix. +The structural `Agent` interface exposes four intent helpers plus the fully resolved acceptance method. The concrete driver implements the matrix once, and each helper supplies its fixed routing and defaults. ```ts type-equiv /** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ @@ -507,6 +528,19 @@ interface Agent { */ inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId + /** + * Accept one fully specified input through the same snapshot and routing path + * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; + * `next-step`/wakeup targets steering (falling back to an ordinary waking turn + * while idle); and `next-step` without wakeup injects durable context without + * running the model. Every field is mandatory and no source or routing default + * is applied. Invalid input throws synchronously before notification, enqueue, + * or append. + * @param input - the resolved content, attribution, context, metadata, and routing facts. + * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. + */ + acceptInput(input: ResolvedAgentInput): AgentMessageId + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5337683ccd..530d4b9b47 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:413`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:344`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:428`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:401`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:439`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:494`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:375`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:391`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:459`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:432`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:470`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:481`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index e9d139c8be..b95ab420af 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 1121d8e9cd..b82fa568f1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 0f196a590a..2f9d679f21 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -52,6 +52,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 6b7da3b19c..7ce1c4da28 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -188,6 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 446ded7d0e..efebae179e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * through `agent.inject()` bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { @@ -1181,7 +1181,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n}', }, { name: 'AgentCancelCause', @@ -1715,6 +1715,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RequestHeaderReason', declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';', }, + { + name: 'ResolvedAgentInput', + declaration: 'export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n} & ({\n target: \'next-turn\';\n wakeup: boolean;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: true;\n contexts: HookContext[];\n} | {\n target: \'next-step\';\n wakeup: false;\n contexts: [\n ];\n});', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index cc5adde987..cdbd512d0d 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -`ReactLoopAgent` maps the public `send()`/`queue()`/`steer()`/`inject()` intents onto native-private `#acceptInput`. Each public method resolves every optional field before the private mechanism receives mandatory content, source, contexts, metadata, target, and wakeup facts; no configurable delivery primitive crosses the package seam. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` accepts no attached contexts, bypasses both FIFOs, and appends durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent.acceptInput()` implements the public fully resolved acceptance path. The `send()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `acceptInput()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6d69d2a740..b42c3c9662 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -17,11 +17,12 @@ import type { CancelOptions, HookContext, InjectOptions, + ResolvedAgentInput, SendOptions, } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type JsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' import { Inbox, agentMessage, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -41,17 +42,6 @@ const bindContext = Symbol('dsh.agent-loop.bind-context') /** Module-private publication marker. */ const publishAgent = Symbol('dsh.agent-loop.publish-agent') -/** Fully resolved input accepted only by the concrete driver's private delivery mechanism. */ -type ResolvedAgentInput = { - content: ContentBlock[] - source: MessageSource - meta: JsonValue | undefined -} & ( - | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } - | { target: 'next-step'; wakeup: true; contexts: HookContext[] } - | { target: 'next-step'; wakeup: false; contexts: [] } -) - /** Factory-owned controls that can operate only on the agent created with them. */ export interface PreparedReactLoopAgent { /** The unpublished concrete agent. */ @@ -241,8 +231,8 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) } - /** Accept one fully resolved agent input through the concrete driver's private routing matrix. */ - #acceptInput(input: ResolvedAgentInput): AgentMessageId { + /** Accept one fully resolved agent input through the concrete driver's routing matrix. */ + acceptInput(input: ResolvedAgentInput): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) const { target, wakeup } = input @@ -262,7 +252,7 @@ export class ReactLoopAgent implements Agent { } send(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-turn', wakeup: true, @@ -273,7 +263,7 @@ export class ReactLoopAgent implements Agent { } queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-turn', wakeup: false, @@ -284,7 +274,7 @@ export class ReactLoopAgent implements Agent { } steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-step', wakeup: true, @@ -295,7 +285,7 @@ export class ReactLoopAgent implements Agent { } inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { - return this.#acceptInput({ + return this.acceptInput({ content, target: 'next-step', wakeup: false, diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 608f1bad60..188bffe1a2 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -83,6 +83,40 @@ describe('Agent', () => { await ctx.fiber.dispose() }) + it('acceptInput exposes the fully resolved delivery path without applying helper defaults', async () => { + const adapter = new MockAdapter([textResponse('accepted')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const enqueued = Promise.withResolvers<{ id: string; source: unknown; wakeup: boolean }>() + ctx.on('agent/inbox/enqueue', (subject, message) => { + if (subject === agent) enqueued.resolve(message) + }) + + const id = agent.acceptInput({ + content: [{ type: 'text', text: 'advanced input' }], + source: { kind: 'plugin', plugin: 'advanced-caller' }, + contexts: [], + meta: { caller: 'advanced' }, + target: 'next-turn', + wakeup: true, + }) + await waitForIdle(ctx, agent) + + expect(await enqueued.promise).toMatchObject({ + id, + source: { kind: 'plugin', plugin: 'advanced-caller' }, + wakeup: true, + }) + expect(agent.session.events.find(event => event.type === 'user/message')) + .toMatchObject({ + data: { + source: { kind: 'plugin', plugin: 'advanced-caller' }, + meta: { caller: 'advanced' }, + }, + }) + await ctx.fiber.dispose() + }) + it('send() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 647cae2b22..b09334d873 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,12 +54,13 @@ Turn and step boundaries and the model token stream are durable `session/event` ### Agent interface (`types.ts`) -`Agent` is a structural interface. Public delivery methods name caller intent; the concrete driver keeps queue targeting and wakeup routing private ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `send()`, `queue()`, and `steer()` return an opaque `AgentMessageId` carried by that FIFO item's `agent/inbox/enqueue`/`dequeue`/`discard` events. Each snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. Omitting `options.source` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. +`Agent` is a structural interface. `send()`, `queue()`, `steer()`, and `inject()` name common caller intents; `acceptInput(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `send()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. - `agent.send(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message. - `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. +- `agent.acceptInput(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata. - `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` @@ -78,7 +79,7 @@ Turn and step boundaries and the model token stream are durable `session/event` #### What the model sees -`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. +The four helpers and `acceptInput` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. #### Token effect diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b120fb6689..3da7ec6e9b 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -68,16 +68,17 @@ export function AgentMessageId(id: string): AgentMessageId { /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value `send`, `queue`, or `steer` returned to the caller, stable across - * this message's enqueue, dequeue, and discard events. Source defaults are - * already applied, so these are the exact values the item was accepted with. + * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * stable across this message's enqueue, dequeue, and discard events. Source + * defaults, when applicable, are already applied, so these are the exact values + * the item was accepted with. * `steering` is true for an item drained between steps; otherwise it is claimed * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable * model-hidden state that lands on the eventual `user/message`/ * `steering/message`, not live-event routing data. */ export interface AgentMessage { - /** The id returned by the accepting `send`, `queue`, or `steer` call. */ + /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -102,7 +103,7 @@ export interface CancelOptions { * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (the driver is draining * work and may be closing or checkpointing a turn), `disposed` (terminal — no - * transition leaves it, and `send`/`queue`/`steer`/`inject` throw). + * transition leaves it, and every delivery method throws). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -120,6 +121,22 @@ export interface HookContext { meta?: JsonValue } +/** + * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * helpers, this form applies no defaults: callers provide content, source, + * contexts, metadata (including explicit `undefined`), target, and wakeup. + * The union excludes attached contexts from non-waking next-step injection. + */ +export type ResolvedAgentInput = { + content: ContentBlock[] + source: MessageSource + meta: JsonValue | undefined +} & ( + | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } + | { target: 'next-step'; wakeup: true; contexts: HookContext[] } + | { target: 'next-step'; wakeup: false; contexts: [] } +) + /** * Prompt interception result. `allow.content` replaces the prompt. Each * `additionalContexts` entry follows its declared placement: separate context @@ -223,6 +240,19 @@ export interface Agent { */ inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId + /** + * Accept one fully specified input through the same snapshot and routing path + * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; + * `next-step`/wakeup targets steering (falling back to an ordinary waking turn + * while idle); and `next-step` without wakeup injects durable context without + * running the model. Every field is mandatory and no source or routing default + * is applied. Invalid input throws synchronously before notification, enqueue, + * or append. + * @param input - the resolved content, attribution, context, metadata, and routing facts. + * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. + */ + acceptInput(input: ResolvedAgentInput): AgentMessageId + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the @@ -275,8 +305,9 @@ declare module 'cordis' { * A detached, frozen item entered the agent's inbox (queued or steering * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record - * is the eventual `user/message`/`steering/message`. Injection - * through `agent.inject()` bypasses the FIFOs and does not emit this. + * is the eventual `user/message`/`steering/message`. Injection through + * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 39a65fa996..9bdb634a2c 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -15,6 +15,7 @@ import type { ContinuationStop, CreateAgentOptions, InjectOptions, + ResolvedAgentInput, ResumeAgentOptions, SendOptions, } from '@deepseek-ai/dsh-agent' @@ -31,6 +32,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, @@ -38,10 +40,20 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent { } describe('AgentRegistry', () => { - it('keeps concrete delivery routing out of public options', () => { + it('keeps helper options semantic and makes advanced input fully specified', () => { + type OptionalInputKey = { + [Key in keyof ResolvedAgentInput]-?: Record<never, never> extends Pick<ResolvedAgentInput, Key> + ? Key + : never + }[keyof ResolvedAgentInput] + expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>() expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>() expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf<false>() + expectTypeOf<Parameters<Agent['acceptInput']>[0]>().toEqualTypeOf<ResolvedAgentInput>() + expectTypeOf<OptionalInputKey>().toEqualTypeOf<never>() + expectTypeOf<Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>['contexts']>() + .toEqualTypeOf<[]>() }) it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => { diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 2611d7f3e6..48c92293c8 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -52,6 +52,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 5f037d3c6b..94c90cf192 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -72,6 +72,7 @@ function stubAgentForSession(session: Session): StubAgent { else appendInjection(session, content, options) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index a419cfd976..3aaa06c447 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -43,6 +43,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index c9aef7ce1f..f32dca3df6 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers<undefined>() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index d0aa8db72e..37e933d827 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 678403ef30..09799ccecd 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -31,6 +31,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 354ebce0ba..a0ec66aff9 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index fce1d05bf7..c974ad90db 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index 51c06904d0..e06fe01aa5 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -27,6 +27,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 36fd40475c..6bf6fcb20d 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -166,6 +166,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e return AgentMessageId('stub') }, inject: () => AgentMessageId('stub'), + acceptInput: () => AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a855d99f81..623f85a3d4 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2499,7 +2499,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2523,7 +2523,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2557,14 +2557,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2594,7 +2594,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2636,7 +2636,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7aee2fc71b..897efa4cb0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -76,6 +76,11 @@ "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ResolvedAgentInput", + "source": "packages/core/agent/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "AgentMessageId", From d3e24356ba5ade49bb44bc4b8dcbc929c8cb0a73 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 13:53:06 +0800 Subject: [PATCH 074/113] refactor(goal): loosen executor presence checks --- packages/goal/tool-goal/src/index.ts | 35 +++++++++---------- .../goal/tool-goal/tests/tool-goal.spec.ts | 8 ++--- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index a0df65a6ed..96e6389f85 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,14 +132,14 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } -/** Remove only empty provider fillers from fields unused by the selected action. */ -function normalizeUpdateArgs(args: Record<string, unknown>): Record<string, unknown> { - const action = args['action'] - const empty = (value: unknown): boolean => value === undefined || value === null || value === '' || value === 0 - const unused = (key: string): boolean => key === 'blocked_reason' - ? action !== 'blocked' - : (key === 'objective' || key === 'max_goal_rounds') && action !== 'edit' - return Object.fromEntries(Object.entries(args).filter(([key, value]) => !unused(key) || !empty(value))) +/** Whether an optional string carries a meaningful action-specific value. */ +function hasText(value: string | undefined): boolean { + return value !== undefined && value !== '' +} + +/** Whether an optional round cap carries a meaningful action-specific value. */ +function hasRoundCap(value: number | undefined): boolean { + return value !== undefined && value !== 0 } /** Build the exact compare-and-set ref from model arguments. */ @@ -254,7 +254,7 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present('Create goal', 'other', args.objective), })) - const updateGoal = defineTool({ + ctx.tools.register(defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' @@ -286,7 +286,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - if (args.blocked_reason !== undefined) { + // Some strict-schema providers emit empty placeholders for every declared + // optional field. Reject only values that could change another action. + if (hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } const goal = ctx.goals.edit(execution.agent, ref, replacements) @@ -295,7 +297,7 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE', @@ -308,13 +310,13 @@ export function apply(ctx: Context, config: Config): void { return Promise.resolve(goalValue(goal)) } const authority = completionAuthority(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit', 'GOAL_TOOL_INVALID_UPDATE', ) } - if (args.action === 'complete' && args.blocked_reason !== undefined) { + if (args.action === 'complete' && hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } if (args.action === 'blocked' @@ -343,10 +345,5 @@ export function apply(ctx: Context, config: Config): void { 'other', args.blocked_reason ?? args.objective ?? args.goal_id, ), - }) - // Object-literal execute methods do not use `this`; retaining the reference is safe. - // eslint-disable-next-line @typescript-eslint/unbound-method - const executeUpdate = updateGoal.execute - updateGoal.execute = (args, exec) => executeUpdate(normalizeUpdateArgs(args as Record<string, unknown>), exec) - ctx.tools.register(updateGoal) + })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index a1261d3cb8..cf48b3dcbc 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -444,7 +444,7 @@ describe('goal tool state transitions', () => { action: 'pause', objective: '', max_goal_rounds: 0, - blocked_reason: null, + blocked_reason: '', }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' }) goal = ctx.goals.get(root.agent)! @@ -453,8 +453,8 @@ describe('goal tool state transitions', () => { goal_id: goal.id, revision: goal.revision, action: 'resume', - objective: null, - max_goal_rounds: '', + objective: '', + max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' }) @@ -465,7 +465,7 @@ describe('goal tool state transitions', () => { revision: goal.revision, action: 'blocked', objective: '', - max_goal_rounds: null, + max_goal_rounds: 0, blocked_reason: 'actual blocker', }, root.agent) expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' }) From c0eacae2ba1cc432eed64471e6f92193ff273011 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:57:16 +0800 Subject: [PATCH 075/113] docs(i18n): align Agent Note terminology --- ...6-06-11-content-block-vocabulary.i18n.yaml | 2 +- .../2026-06-11-content-block-vocabulary.zh.md | 2 +- .../2026-06-11-custom-schema-dsl.i18n.yaml | 2 +- .../2026-06-11-custom-schema-dsl.zh.md | 2 +- ...ev-invariants-over-deep-readonly.i18n.yaml | 2 +- ...11-dev-invariants-over-deep-readonly.zh.md | 4 +- ...026-06-11-event-sourced-sessions.i18n.yaml | 2 +- .../2026-06-11-event-sourced-sessions.zh.md | 2 +- ...-06-11-structured-error-taxonomy.i18n.yaml | 2 +- ...2026-06-11-structured-error-taxonomy.zh.md | 2 +- ...-tool-schemas-in-prompt-assembly.i18n.yaml | 2 +- ...6-11-tool-schemas-in-prompt-assembly.zh.md | 2 +- .../2026-06-13-capability-seams.i18n.yaml | 2 +- .../2026-06-13-capability-seams.zh.md | 2 +- .../2026-06-14-session-persistence.i18n.yaml | 2 +- .../2026-06-14-session-persistence.zh.md | 8 +-- ...nt-lifecycle-and-ownership-seams.i18n.yaml | 2 +- ...-agent-lifecycle-and-ownership-seams.zh.md | 14 ++-- .../2026-06-18-session-surface.i18n.yaml | 2 +- .../2026-06-18-session-surface.zh.md | 2 +- ...ed-persistence-write-coordinator.i18n.yaml | 2 +- ...shared-persistence-write-coordinator.zh.md | 2 +- .../2026-06-20-branded-ids.i18n.yaml | 2 +- .../architecture/2026-06-20-branded-ids.zh.md | 16 ++--- ...-20-extract-example-app-packages.i18n.yaml | 2 +- ...6-06-20-extract-example-app-packages.zh.md | 6 +- .../2026-06-20-package-hierarchy.i18n.yaml | 2 +- .../2026-06-20-package-hierarchy.zh.md | 2 +- .../2026-06-24-web-capability-seam.i18n.yaml | 2 +- .../2026-06-24-web-capability-seam.zh.md | 18 +++--- ...06-26-file-context-as-event-gate.i18n.yaml | 2 +- ...026-06-26-file-context-as-event-gate.zh.md | 8 +-- ...026-06-30-event-domain-semantics.i18n.yaml | 2 +- .../2026-06-30-event-domain-semantics.zh.md | 10 +-- .../2026-07-02-fs-per-session-cwd.i18n.yaml | 2 +- .../2026-07-02-fs-per-session-cwd.zh.md | 4 +- ...6-07-02-tool-render-intent-union.i18n.yaml | 2 +- .../2026-07-02-tool-render-intent-union.zh.md | 2 +- ...ilesystem-directory-listing-seam.i18n.yaml | 2 +- ...03-filesystem-directory-listing-seam.zh.md | 6 +- ...bles-and-tool-guidance-ownership.i18n.yaml | 2 +- ...ariables-and-tool-guidance-ownership.zh.md | 38 +++++------ ...6-07-05-reconstructable-requests.i18n.yaml | 2 +- .../2026-07-05-reconstructable-requests.zh.md | 10 +-- ...bagent-provider-lifecycle-events.i18n.yaml | 2 +- ...5-subagent-provider-lifecycle-events.zh.md | 6 +- ...6-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 6 +- .../2026-07-08-agent-scope-contexts.i18n.yaml | 2 +- .../2026-07-08-agent-scope-contexts.zh.md | 20 +++--- ...07-12-agent-scope-runtime-design.i18n.yaml | 2 +- ...026-07-12-agent-scope-runtime-design.zh.md | 64 +++++++++---------- ...-06-14-acp-agent-client-protocol.i18n.yaml | 2 +- ...2026-06-14-acp-agent-client-protocol.zh.md | 20 +++--- .../2026-06-14-acp-multi-session.i18n.yaml | 2 +- .../2026-06-14-acp-multi-session.zh.md | 14 ++-- .../feature/2026-06-15-code-mode.i18n.yaml | 2 +- .../feature/2026-06-15-code-mode.zh.md | 16 ++--- ...06-18-compaction-capability-seam.i18n.yaml | 2 +- ...026-06-18-compaction-capability-seam.zh.md | 4 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 4 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 2 +- .../2026-06-22-acp-subagent-backend.zh.md | 4 +- .../2026-06-25-ask-user-question.i18n.yaml | 2 +- .../2026-06-25-ask-user-question.zh.md | 16 ++--- .../2026-06-29-todo-write-tool.i18n.yaml | 2 +- .../feature/2026-06-29-todo-write-tool.zh.md | 4 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 6 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 12 ++-- .../2026-06-30-interception-seams.i18n.yaml | 2 +- .../2026-06-30-interception-seams.zh.md | 22 +++---- ...026-06-30-session-store-fork-api.i18n.yaml | 2 +- .../2026-06-30-session-store-fork-api.zh.md | 6 +- ...26-06-30-subagent-observe-enrich.i18n.yaml | 2 +- .../2026-06-30-subagent-observe-enrich.zh.md | 6 +- .../2026-07-05-dynamic-workflows.i18n.yaml | 2 +- .../2026-07-05-dynamic-workflows.zh.md | 12 ++-- .../2026-07-06-explicit-tool-order.i18n.yaml | 2 +- .../2026-07-06-explicit-tool-order.zh.md | 6 +- .../feature/2026-07-06-sandbox.i18n.yaml | 2 +- .../feature/2026-07-06-sandbox.zh.md | 26 ++++---- .../2026-07-07-mcp-client-plugin.i18n.yaml | 2 +- .../2026-07-07-mcp-client-plugin.zh.md | 2 +- .../2026-07-07-session-prefix.i18n.yaml | 2 +- .../feature/2026-07-07-session-prefix.zh.md | 10 +-- .../2026-07-08-repeat-tool-guard.i18n.yaml | 2 +- .../2026-07-08-repeat-tool-guard.zh.md | 6 +- ...-self-referential-cordis-toolset.i18n.yaml | 2 +- ...7-08-self-referential-cordis-toolset.zh.md | 4 +- ...nt-persona-tool-filter-and-depth.i18n.yaml | 2 +- ...bagent-persona-tool-filter-and-depth.zh.md | 12 ++-- .../2026-06-11-doc-sync-enforcement.i18n.yaml | 2 +- .../2026-06-11-doc-sync-enforcement.zh.md | 2 +- .../2026-06-11-quality-gates.i18n.yaml | 2 +- .../process/2026-06-11-quality-gates.zh.md | 6 +- .../2026-06-11-tsdown-over-dumble.i18n.yaml | 2 +- .../2026-06-11-tsdown-over-dumble.zh.md | 2 +- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 2 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 2 +- .../2026-06-17-ts-build-config.i18n.yaml | 2 +- .../process/2026-06-17-ts-build-config.zh.md | 16 ++--- ...-20-core-data-structures-catalog.i18n.yaml | 2 +- ...6-06-20-core-data-structures-catalog.zh.md | 8 +-- .../2026-07-02-tool-schema-catalog.i18n.yaml | 2 +- .../2026-07-02-tool-schema-catalog.zh.md | 2 +- ...-07-03-documentation-graph-atlas.i18n.yaml | 2 +- ...2026-07-03-documentation-graph-atlas.zh.md | 6 +- ...2026-07-04-doc-tiers-and-budgets.i18n.yaml | 2 +- .../2026-07-04-doc-tiers-and-budgets.zh.md | 2 +- ...26-07-04-persistence-log-catalog.i18n.yaml | 2 +- .../2026-07-04-persistence-log-catalog.zh.md | 6 +- ...-07-05-uniform-agent-note-format.i18n.yaml | 2 +- ...2026-07-05-uniform-agent-note-format.zh.md | 2 +- ...6-07-06-generated-config-catalog.i18n.yaml | 2 +- .../2026-07-06-generated-config-catalog.zh.md | 10 +-- .../2026-07-06-node-engine-floor.i18n.yaml | 2 +- .../2026-07-06-node-engine-floor.zh.md | 4 +- ...6-07-06-parallel-github-ci-gates.i18n.yaml | 2 +- .../2026-07-06-parallel-github-ci-gates.zh.md | 4 +- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 2 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 2 +- ...10-readme-known-limitations-gate.i18n.yaml | 2 +- ...-07-10-readme-known-limitations-gate.zh.md | 4 +- ...ackage-model-experience-contract.i18n.yaml | 2 +- ...12-package-model-experience-contract.zh.md | 18 +++--- ...-19-drop-mutable-session-summary.i18n.yaml | 2 +- ...6-06-19-drop-mutable-session-summary.zh.md | 2 +- ...llapse-trace-only-session-events.i18n.yaml | 2 +- ...0-collapse-trace-only-session-events.zh.md | 2 +- ...onsumed-llm-adapter-change-event.i18n.yaml | 2 +- ...-unconsumed-llm-adapter-change-event.zh.md | 8 +-- ...nconsumed-llm-assembled-surfaces.i18n.yaml | 2 +- ...op-unconsumed-llm-assembled-surfaces.zh.md | 6 +- ...26-06-20-prune-dead-seam-methods.i18n.yaml | 2 +- .../2026-06-20-prune-dead-seam-methods.zh.md | 16 ++--- ...-06-20-public-agent-stop-surface.i18n.yaml | 2 +- ...2026-06-20-public-agent-stop-surface.zh.md | 2 +- ...ove-agent-boundary-mirror-events.i18n.yaml | 2 +- ...-remove-agent-boundary-mirror-events.zh.md | 8 +-- ...06-20-unify-agent-and-session-id.i18n.yaml | 2 +- ...026-06-20-unify-agent-and-session-id.zh.md | 16 ++--- .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 2 +- .../2026-06-26-fsspec-style-fs-seam.zh.md | 6 +- ...07-02-remove-stream-chunk-mirror.i18n.yaml | 2 +- ...026-07-02-remove-stream-chunk-mirror.zh.md | 12 ++-- ...6-07-04-drop-image-content-block.i18n.yaml | 2 +- .../2026-07-04-drop-image-content-block.zh.md | 6 +- ...6-07-04-drop-inert-request-knobs.i18n.yaml | 2 +- .../2026-07-04-drop-inert-request-knobs.zh.md | 10 +-- ...consumed-web-observation-surface.i18n.yaml | 2 +- ...p-unconsumed-web-observation-surface.zh.md | 12 ++-- ...producerless-vocabulary-variants.i18n.yaml | 2 +- ...une-producerless-vocabulary-variants.zh.md | 10 +-- ...-04-remove-agent-steering-mirror.i18n.yaml | 2 +- ...6-07-04-remove-agent-steering-mirror.zh.md | 4 +- ...4-tighten-hook-protocol-contract.i18n.yaml | 2 +- ...07-04-tighten-hook-protocol-contract.zh.md | 6 +- ...m-acp-bridge-unreachable-surface.i18n.yaml | 2 +- ...-trim-acp-bridge-unreachable-surface.zh.md | 2 +- ...plify-session-log-representation.i18n.yaml | 2 +- ...-simplify-session-log-representation.zh.md | 6 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 2 +- .../2026-06-19-acp-snapshot-tests.zh.md | 30 ++++----- .../2026-06-19-real-api-e2e-ci.i18n.yaml | 2 +- .../testing/2026-06-19-real-api-e2e-ci.zh.md | 2 +- ...ant-snapshot-log-expected-output.i18n.yaml | 2 +- ...dundant-snapshot-log-expected-output.zh.md | 8 +-- ...-fork-child-replay-seed-boundary.i18n.yaml | 2 +- ...6-22-fork-child-replay-seed-boundary.zh.md | 8 +-- ...26-06-22-fork-snapshot-scenarios.i18n.yaml | 2 +- .../2026-06-22-fork-snapshot-scenarios.zh.md | 8 +-- ...6-06-22-subagent-snapshot-replay.i18n.yaml | 2 +- .../2026-06-22-subagent-snapshot-replay.zh.md | 2 +- .../2026-07-04-hook-snapshot-matrix.i18n.yaml | 2 +- .../2026-07-04-hook-snapshot-matrix.zh.md | 26 ++++---- ...t-header-content-in-one-scenario.i18n.yaml | 2 +- ...quest-header-content-in-one-scenario.zh.md | 10 +-- ...7-08-shared-acp-snapshot-package.i18n.yaml | 2 +- ...26-07-08-shared-acp-snapshot-package.zh.md | 12 ++-- .../2026-06-16-typed-event-schemas.i18n.yaml | 2 +- .../2026-06-16-typed-event-schemas.zh.md | 4 +- ...026-06-30-pre-tool-input-rewrite.i18n.yaml | 2 +- .../2026-06-30-pre-tool-input-rewrite.zh.md | 2 +- ...code-and-codex-subagent-backends.i18n.yaml | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 24 +++---- ...-07-08-interactive-side-sessions.i18n.yaml | 2 +- ...2026-07-08-interactive-side-sessions.zh.md | 8 +-- ...flow-progress-through-tool-calls.i18n.yaml | 2 +- ...workflow-progress-through-tool-calls.zh.md | 8 +-- ...04-prune-dead-core-spine-surface.i18n.yaml | 2 +- ...-07-04-prune-dead-core-spine-surface.zh.md | 12 ++-- ...deterministic-and-stress-testing.i18n.yaml | 2 +- ...-11-deterministic-and-stress-testing.zh.md | 4 +- ...-06-11-immutable-public-surfaces.i18n.yaml | 2 +- ...2026-06-11-immutable-public-surfaces.zh.md | 4 +- ...-06-20-providerless-example-base.i18n.yaml | 2 +- ...2026-06-20-providerless-example-base.zh.md | 8 +-- ...generate-agent-note-index-tables.i18n.yaml | 2 +- ...-04-generate-agent-note-index-tables.zh.md | 2 +- ...ssembled-assistant-messages-only.i18n.yaml | 2 +- ...20-assembled-assistant-messages-only.zh.md | 2 +- ...2026-06-20-drop-acp-session-load.i18n.yaml | 2 +- .../2026-06-20-drop-acp-session-load.zh.md | 4 +- ...026-06-20-drop-acp-terminal-meta.i18n.yaml | 2 +- .../2026-06-20-drop-acp-terminal-meta.zh.md | 4 +- ...-20-drop-bash-output-spill-files.i18n.yaml | 2 +- ...6-06-20-drop-bash-output-spill-files.zh.md | 2 +- ...-20-drop-durable-step-boundaries.i18n.yaml | 2 +- ...6-06-20-drop-durable-step-boundaries.zh.md | 2 +- ...6-20-drop-unused-session-lineage.i18n.yaml | 2 +- ...26-06-20-drop-unused-session-lineage.zh.md | 4 +- ...ld-session-persistence-interface.i18n.yaml | 2 +- ...0-fold-session-persistence-interface.zh.md | 6 +- ...026-06-20-generic-tool-rendering.i18n.yaml | 2 +- .../2026-06-20-generic-tool-rendering.zh.md | 6 +- ...6-06-20-retire-mid-turn-steering.i18n.yaml | 2 +- .../2026-06-20-retire-mid-turn-steering.zh.md | 6 +- ...-06-20-single-session-acp-bridge.i18n.yaml | 2 +- ...2026-06-20-single-session-acp-bridge.zh.md | 4 +- ...nimplemented-subagent-vocabulary.i18n.yaml | 2 +- ...ne-unimplemented-subagent-vocabulary.zh.md | 8 +-- ...apse-workflow-to-foreground-core.i18n.yaml | 2 +- ...collapse-workflow-to-foreground-core.zh.md | 6 +- ...ne-unused-skill-registry-surface.i18n.yaml | 2 +- ...-prune-unused-skill-registry-surface.zh.md | 4 +- 228 files changed, 584 insertions(+), 584 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 95bc498102..01823c55d0 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-content-block-vocabulary.md: 9aad01cee6083b1f380be66869af3137a07d9f1f -2026-06-11-content-block-vocabulary.zh.md: 1427ef1a47ee3a54c1c93885befdf580b84f6399 +2026-06-11-content-block-vocabulary.zh.md: 5720f0742a0729a3f98e4b05ab37acf97ae78db5 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index 1427ef1a47..5720f0742a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -25,4 +25,4 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 - 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 -- 跨包(package)边界的 ID 使用品牌类型(`CallId`、agent 与 session 共享的 `SessionId`)——零运行时开销的名义类型。 +- 跨包(package)边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml index 172b94720d..41265a5b0f 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-custom-schema-dsl.md: 947d53555df078bfa9f3dac48eab4b8c0074007c -2026-06-11-custom-schema-dsl.zh.md: 7317e950a4d9822edfca190ccfc22b65809f078a +2026-06-11-custom-schema-dsl.zh.md: 26ebfe2fb15a6c034e809b3f51187342fa500193 diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md index 7317e950a4..26ebfe2fb1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 使用自定义类型化 tool-schema DSL 替代 schemastery +# Agent Note: 使用自定义类型化工具 schema DSL 替代 schemastery Status: implemented diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index a3029832c5..c6ddb39d01 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-dev-invariants-over-deep-readonly.md: 8f0e79f15af82ce3125b1f6f767d4ea727aa6d29 -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 9998ddd784f956e3497a0bb9d8f5096047f14a70 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 2f787bbd55b5a9a91bc5342351756e45cb0515d3 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 9998ddd784..2f787bbd55 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -34,7 +34,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 `dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要 trace 状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。 -当 session 配套插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 +当会话配套插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 ## 曾考虑的替代方案 @@ -56,5 +56,5 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 - `session.events` 暴露稳定的不可变快照,而非私有的增长数组。 - 请求侧的修改无法通过派生消息触及已存储的历史。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。 -- `dsh-invariants` 配置全局启用状态以及包 allow/block regex 列表;每项检查仍由其产品包拥有并测试。 +- `dsh-invariants` 配置全局启用状态以及包允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。 - 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 3ea1c711c4..6ea6fce11e 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-event-sourced-sessions.md: 15ba7b23d5eae48e7dee2328b5924493d54aeeb0 -2026-06-11-event-sourced-sessions.zh.md: fc900b0dc43b18ba2016b4dd57584cf15707b016 +2026-06-11-event-sourced-sessions.zh.md: da3be5965a6900076f253cad065b847c6f5ce17e diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index fc900b0dc4..da3be5965a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严格的基于事件的 trace、logging 系统,session 完全可回放)。 +MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严格的基于事件的 trace、logging 系统,会话完全可回放)。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml index b886be0e38..ca9d2117ec 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-structured-error-taxonomy.md: 9122193b3d01cf5a4c315e6f7a7218153fd4a60a -2026-06-11-structured-error-taxonomy.zh.md: cb29503314edf248c90f2eecd54e01f5b203b99c +2026-06-11-structured-error-taxonomy.zh.md: 56a196ccd10a81b51953887f18e522412cd9463b diff --git a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md index cb29503314..56a196ccd1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 后果 -- 错误端到端可机器路由:插件可以基于 `error.code` 分支,而无需对 message 做子串匹配。 +- 错误端到端可机器路由:插件可以基于 `error.code` 分支,而无需对消息做子串匹配。 - 一个基类被广泛导入,但它位于所有包已经依赖的包中,代价仅是一条 import 语句,而非新的依赖边。 - `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。 - 参数校验保留其既有的 code 和行为;包自有的诊断不变式独立携带稳定 code,使不变式注册表无需导入产品包。共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml index 61b72fe101..61ecb29ca3 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-tool-schemas-in-prompt-assembly.md: 3643ac3d61be08f629ef0cd0424fef5cb9696c3a -2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 645b829627674e5ccd1507002309b7e8364ac59f +2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 10389fd7c63755e5b00b3c508fd303a541287f2c diff --git a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md index 645b829627..10389fd7c6 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md @@ -20,4 +20,4 @@ Status: implemented - 一条 waterfall 统管模型的常驻上下文;plan 模式等插件可以在一个监听器中同时替换提示词文本和可见工具。 - assembly 接口通过声明合并实现可扩展(没有无类型的 `extras` 包——扩展即声明合并),为未来的槽位预留空间。 -- 将 schema 放在「提示词」服务中略有概念上的意外感,已在本文及 package README 中加以说明。 +- 将 schema 放在「提示词」服务中略有概念上的意外感,已在本文及包 README 中加以说明。 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index 3cdeabed71..63063f8d5f 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-13-capability-seams.md: 7c755dced7825d2831acc0901f6412b8e5afe95a -2026-06-13-capability-seams.zh.md: f3aa1ebdee3a16bf095f317b86d01e00b6bbf0c7 +2026-06-13-capability-seams.zh.md: 4148c79cb5e1930dca77eaf3afd2024f508275b5 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md index f3aa1ebdee..4148c79cb5 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -8,7 +8,7 @@ Status: implemented harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*(模型和其他插件面向什么编程)。将三者捆绑在一个包(package)中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。 -这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过 service + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 Agent Note 决定的是包的边界。 +这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过服务 + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 Agent Note 决定的是包的边界。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 35b81801d0..411a4c41a0 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-14-session-persistence.md: 1122a52471c6279eff7454cfd31692f05a7bba76 -2026-06-14-session-persistence.zh.md: 0f2bcd4948a3c95cd0497cfa63ef4454630635c7 +2026-06-14-session-persistence.zh.md: ecaefb0eb4fc0993ad0792cef9060008b1b3e4fd diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index 0f2bcd4948..ecaefb0eb4 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -19,15 +19,15 @@ Status: implemented 以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的: -- **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk,而过滤 chunk 的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的 provider transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 以明确的错误拒绝。 +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 -上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 chunk 的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 +上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml index a0ab7d1c83..943ac3761e 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-agent-lifecycle-and-ownership-seams.md: 70ebf1c6de97cb14e27377ec1f9bac18fc0766a6 -2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 2091182b663dfbd1ac306550de60c1f00d12656f +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 285c42fd8a8086b5a1ee0b9f79112b048099077a diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md index 2091182b66..285c42fd8a 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -10,17 +10,17 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se ## 决策 -三个 seam:队列感知的 cancel、`AgentHandle` 释放器,以及 bash 所有者令牌。 +三个 seam:队列感知的取消、`AgentHandle` 释放器,以及 bash 所有者令牌。 ### 1. 队列感知的 `Agent.cancel(cause?)` -`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在取得所有权前被取消的 prompt 永不运行,而后来的 prompt 仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条 prompt 搁浅。`whenIdle()` 会到达取消后的静默状态,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)拥有当前的 cause、signal 生命周期与协作式结算契约。 +`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在取得所有权前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会到达取消后的静默状态,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)拥有当前的 cause、signal 生命周期与协作式结算契约。 ### 2. `AgentHandle` 异步释放器 -`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(真正的静默,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个会话的释放器,并在断连/拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或 session-store 条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)。 +`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(真正的静默,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个会话的释放器,并在断连/拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)。 -**拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect(`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(`Promise.all`),这会让 session store 的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed` 和 `session/disposed` 通知无法拒绝该链或跳过后续拆除。 +**拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect(`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(`Promise.all`),这会让会话存储的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed` 和 `session/disposed` 通知无法拒绝该链或跳过后续拆除。 ### 3. Bash seam 中的所有者令牌 @@ -30,14 +30,14 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se 以下不变式已经成立,并由测试固定: -- ACP 断连/会话关闭后,不留下该会话的任何已注册 agent 或 session-store 条目,即使 `session/load` 与拆除竞争。 -- 已入队的 prompt 启动前执行 `session/cancel`,能阻止该 prompt 运行;后来接受的 prompt 仍是独立的已入队轮次。 +- ACP 断连/会话关闭后,不留下该会话的任何已注册 agent 或会话存储条目,即使 `session/load` 与拆除竞争。 +- 已入队的提示词启动前执行 `session/cancel`,能阻止该提示词运行;后来接受的提示词仍是独立的已入队轮次。 - `tool-bash` HMR 重载不会使另一个会话能够读取或终止已有的后台任务(所有权保留在执行器上)。 - 既有的非 ACP 演示无需显式管理 handle 仍能工作;由配置创建的 agent 仍归 `AgentLoop` 插件 fiber 所有。 ## 会话所有者令牌在存活 agent 中唯一 -bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布会依次进入 session 和 agent;`SessionStore.enter()` 拒绝重复的存活 session id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问*策略*(token 比较)留在消费方 `tool-bash`;bash seam 只存储不透明的 `owner` 字符串且从不解释它——这是正确的接口/实现/消费方拆分。 +bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布会依次进入会话和 agent;`SessionStore.enter()` 拒绝重复的存活会话 id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问*策略*(token 比较)留在消费方 `tool-bash`;bash seam 只存储不透明的 `owner` 字符串且从不解释它——这是正确的接口/实现/消费方拆分。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml index 578187d1aa..4946fc219a 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-session-surface.md: 80034881d0112076759a68737b5931c8ff659d15 -2026-06-18-session-surface.zh.md: 49303c87aa87569bcc61f215642c9b7f0c408dbf +2026-06-18-session-surface.zh.md: 26a3119faf0b6988049a7599ea9551a8ae65d63d diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md index 49303c87aa..26a3119faf 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md @@ -63,7 +63,7 @@ export type SurfaceOp = ## 后果 - **`packages/core/session`**:`surface.ts`(`SurfaceManager`)维护一个用于候选接纳和实时投影的有序 seq 数组;`SessionSurface` 是其只读公共视图。`SurfaceOp`/`SurfaceIntent` 与顶层会话事件字段记录条目如何加入它。`append()` 要求 surface 事件携带 `SurfaceIntent`,`deriveMessages()` 以遍历 surface 作为唯一派生路径,`repair.ts` 则发出 surface 感知的闭合事件。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。 -- **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 +- **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集分片 seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。 - **`packages/session-persistence/session-persistence-sqlite`**:`events` 表新增两个可空 TEXT 列(`source_event_seqs`、`surface_op`);`SCHEMA_VERSION` 递增(bump-and-reject,无迁移)。 - **`packages/session-persistence/session-persistence-jsonl`**:无需改动。 - **`packages/session-persistence/session-persistence`**:抽象接口不变。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 54074d8781..dcd225a162 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-shared-persistence-write-coordinator.md: ea9c4fb74f7c1bd68fb62efedd3e1657da96ea65 -2026-06-18-shared-persistence-write-coordinator.zh.md: 31a66e98800510f0ca5a8e3693d98af4264ea4b5 +2026-06-18-shared-persistence-write-coordinator.zh.md: 55b6aca31cb75ff58f032ad49569e25b13baee53 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 31a66e9880..55b6aca31c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但它们重复实现了写入路径编排:per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 控制、按 id 串行执行操作、HMR(热模块替换)种子注入与 dispose(资源释放)排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。唯一的差异在于存储原语(写字节 vs. INSERT 行)。 +`dsh-session-persistence-jsonl` 与 `dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约,但它们重复实现了写入路径编排:每会话状态、`session/created` 接管、后端特定的前缀读取、write-behind 控制、按 id 串行执行操作、HMR(热模块替换)种子注入与 dispose(资源释放)排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。唯一的差异在于存储原语(写字节 vs. INSERT 行)。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index d1a6a000db..35c9875149 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-branded-ids.md: 93bab1d47c793cc4dd1f1d19fa3af22d1721be29 -2026-06-20-branded-ids.zh.md: edf5cade38b579346f34a7ed2ee0a876af7a4c8b +2026-06-20-branded-ids.zh.md: 6cc8a45717b547614a873d3f13c54c3950484a22 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index edf5cade38..6cc8a45717 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和共享的 agent/session `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包(package) `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 +harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包(package) `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 -**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 task id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和 session id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 +**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 task id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/bash/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 -**缺口 2:*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括 session store、agent 注册表(二者都以共享的 `SessionId` 为键)、`ToolPresenter` 的 call-id map、ACP 的 session-id 记录和 loading set,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 +**缺口 2:*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、`ToolPresenter` 的 call-id map、ACP 的会话 id 记录和 loading set,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 ## 决策 @@ -46,13 +46,13 @@ export function OwnerToken(id: string): OwnerToken { ### 为什么不把 `owner` 类型标注为 `SessionId`? -显而易见的捷径是直接把 `owner` 类型标注为 `SessionId`——它确实*总是*一个 session id。我们否决这个方案。bash 执行器 seam 是能力 seam(接口 `dsh-bash`、实现 `dsh-bash-local`、消费方 `dsh-tool-bash`),其 owner token 被*明确记录为刻意不透明*:执行器「从不解释它(seam 中没有访问策略——那是消费方的职责)」(`packages/bash/bash/src/types.ts`)。把 seam 字段类型标注为 `SessionId`,会把 `dsh-session` 的词汇引入一个不应知道 owner token *含义*的包——这会让通用执行后端耦合 session 模型,并违背不透明 token 的设计。取代 `dsh-bash-local` 的沙箱或远程执行器不应继承 session 依赖。独立的 `OwnerToken` brand 使 seam 保持解耦:`dsh-bash` 只知道「owner 是某种带 brand 的不透明 token」,而已经决定访问策略的 `dsh-tool-bash` 消费方,是把其 `SessionId` cast 为 `OwnerToken` 的唯一边界。该 brand 仍带来安全收益(不能把 `BashTaskId` 或裸 string 传到 owner 位置),且不引入耦合。 +显而易见的捷径是直接把 `owner` 类型标注为 `SessionId`——它确实*总是*一个会话 id。我们否决这个方案。bash 执行器 seam 是能力 seam(接口 `dsh-bash`、实现 `dsh-bash-local`、消费方 `dsh-tool-bash`),其 owner token 被*明确记录为刻意不透明*:执行器「从不解释它(seam 中没有访问策略——那是消费方的职责)」(`packages/bash/bash/src/types.ts`)。把 seam 字段类型标注为 `SessionId`,会把 `dsh-session` 的词汇引入一个不应知道 owner token *含义*的包——这会让通用执行后端耦合会话模型,并违背不透明 token 的设计。取代 `dsh-bash-local` 的沙箱或远程执行器不应继承会话依赖。独立的 `OwnerToken` brand 使 seam 保持解耦:`dsh-bash` 只知道「owner 是某种带 brand 的不透明 token」,而已经决定访问策略的 `dsh-tool-bash` 消费方,是把其 `SessionId` cast 为 `OwnerToken` 的唯一边界。该 brand 仍带来安全收益(不能把 `BashTaskId` 或裸 string 传到 owner 位置),且不引入耦合。 ## 不在范围内 / 可能的扩展 遵循「不是每个 string 都需要 brand」的策略,刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺: -- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → adapter);合理的下一个 brand,仅为控制本 Agent Note 的影响范围而暂不纳入。 +- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → 适配器);合理的下一个 brand,仅为控制本 Agent Note 的影响范围而暂不纳入。 - **`ToolName`**(`ToolRegistry` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。 - **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。 - **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。 @@ -60,10 +60,10 @@ export function OwnerToken(id: string): OwnerToken { ## 验证 -已落地的不变式:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,并端到端贯穿执行器 seam、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的 surface,且 `dsh-bash` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`CallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP session id、模型提供的 `task_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。 +已落地的不变式:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,并端到端贯穿执行器 seam、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的 surface,且 `dsh-bash` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`CallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `task_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。 ## 后果 -- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及 session-id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 -- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 Agent Note 不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 +- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(接口 + 实现 + 消费方)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 +- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* 会话 id 只要仍是合法的 string,就和以前一样能通过类型检查器。本 Agent Note 不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 - **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 Agent Note 倾向于面向模型或用于访问控制的 id。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml index 71df095880..35bc3836ff 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-extract-example-app-packages.md: 8e87a7164d9cc4789705b3d8fe83dc4978b4def0 -2026-06-20-extract-example-app-packages.zh.md: b2877f25b8e7e049a2def7acddc96e67fa107ff9 +2026-06-20-extract-example-app-packages.zh.md: bb5bf6a78d749bf2fc0637a48bff4496399a787d diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md index b2877f25b8..bb5bf6a78d 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。 +示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/持久化/系统提示词配置。真正的应用——每个 agent(智能体)都需要的服务主干——散落在叶子配置和那些 include 中。 叶子配置还拥有耦合的前门。ACP(Agent Client Protocol)要求 stdout 纯净,并通过 `session/new` 创建 agent;终端应用和 Headless 应用则预创建 `main`,但进程 I/O 契约不同。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复着 Loader 引导和生命周期代码。 @@ -14,7 +14,7 @@ Status: implemented 每个示例现在**主要是对一个应用包(package)的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**应用包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM(大语言模型)适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。 -- **`@deepseek-ai/dsh-agent-spine-demo`**([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含 provider、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。 +- **`@deepseek-ai/dsh-agent-spine-demo`**([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含提供方、不含执行器、不含 UI 的主干,并转发 agent loop(智能体循环)的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。 - **`@deepseek-ai/dsh-tui-demo`**、**`@deepseek-ai/dsh-cli-demo`** 和 **`@deepseek-ai/dsh-acp-demo`** 各自内置其进程角色。TUI 包含全屏 UI 和预创建的 `main`;Headless 包含 one-shot driver 和预创建的 `main`;ACP 包含 bridge 且不预创建 agent。三者都包含 JSONL 持久化,并省略 stdout logger。 - **`start.ts` 已移除。** 每个应用包都暴露一个 bin;`demo:*` 脚本调用它。Loader 引导、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));精简的自执行入口由 keyless 的 Loader 路径测试驱动。 - **每个叶子 `cordis.yml` 精简为**后端、可选产品工具,以及一个承载应用配置的 app 条目。TUI 和 Headless 把模型/会话选择路由到预创建的 agent;ACP 把初始提供方/模型路由到 bridge。 @@ -51,7 +51,7 @@ Status: implemented ## 相关 -- 取代[使共享示例基础配置与提供方无关](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。 +- 取代[使共享示例基础配置与提供方无关](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo` 且 `base*.yml` 文件被删除,将 `base.yml` 重命名为无提供方核心便不再有意义。 - 基于[能力 seam](2026-06-13-capability-seams.md)的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。 - 与[将包重组为模块化层级结构](2026-06-20-package-hierarchy.md)互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle,`ui` 放应用特有的前门)。 - 后续的[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)拥有最终的 TUI/Headless 拆分,并移除行式与仅 mock 的叶子。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml index e2b58386ea..63ef106781 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-package-hierarchy.md: ab0732172934414f2361b9e4976b54e5daee298c -2026-06-20-package-hierarchy.zh.md: a347b055d92d4cc6431833765bed083c92bc04c7 +2026-06-20-package-hierarchy.zh.md: 57058860de5b4cba3e8a45ecad9d611d068fb82c diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md index a347b055d9..57058860de 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -55,7 +55,7 @@ packages/ 包列表此前在五个地方重复枚举。统一的深度 2 布局使大部分可以被推导: -- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选)映射所有包,取代了逐包条目。聚合配置(`tsconfig.host.json`、`tsconfig.client.json`)复用该源映射,并携带显式 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选中包含 `/*/`,朴素的正则注释剥离器会将其误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手动剥离注释。) +- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选)映射所有包,取代了逐包条目。聚合配置(`tsconfig.host.json`、`tsconfig.client.json`)复用该源映射,并携带显式 project references 以保持包/vendor 类型检查边界完整。(这里引入了一个细节:路径候选中包含 `/*/`,朴素的正则注释剥离器会将其误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手动剥离注释。) - `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导列表,解决了 `TODO(package-inventory)`。 - 聚合配置的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest(元数据清单)生成这些引用留作后续工作(见[通过发现机制获取包清单](../../proposed/process/2026-06-20-discover-package-inventory.md))。 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 15dc1acece..70ec051143 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-24-web-capability-seam.md: 4f4e821fec9494fe9ea96894267707d9dd202e4d -2026-06-24-web-capability-seam.zh.md: 1d81d06d7ff0202174f4348146117e22ea1de038 +2026-06-24-web-capability-seam.zh.md: d7c07a8ae0365c321120102a0af401d85d7e2eae diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 1d81d06d7f..d7c07a8ae0 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -10,7 +10,7 @@ harness 需要面向模型的 web 工具,但不能将模型契约绑定到某 面向模型的接口必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。 -如果把搜索和 fetch 直接放进 `dsh-tool-web`,面向模型的工具就要同时承担提供方选择、后端请求映射、传输策略、结果归一化、prompt 引导、展示和 schema 注册。让每个提供方注册自己的工具则有相反的问题:工具的可用性、名称、描述和参数将取决于恰好加载了哪些提供方包,提供方特有字段会泄漏到模型契约中。 +如果把搜索和 fetch 直接放进 `dsh-tool-web`,面向模型的工具就要同时承担提供方选择、后端请求映射、传输策略、结果归一化、提示词引导、展示和 schema 注册。让每个提供方注册自己的工具则有相反的问题:工具的可用性、名称、描述和参数将取决于恰好加载了哪些提供方包,提供方特有字段会泄漏到模型契约中。 还有一个提供方选择的问题。现有的 `tool-bash` 和 `tool-fs` 可以依赖 Cordis 的 `inject`,因为只有一个后端服务键。Web 有两项独立能力(`search` 和 `fetch`),每项能力可能有多个提供方。`inject: ['web']` 能证明 seam 存在,但不能证明存在可用的搜索或 fetch 提供方,也无法定义多个提供方注册时谁胜出。 @@ -20,9 +20,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-local`。 -3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、prompt 段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。 +3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、提示词段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。 -提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、prompt 引导、JSON Schema、展示的唯一所有者。 +提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、提示词引导、JSON Schema、展示的唯一所有者。 搜索和 fetch 是两个独立工具,但属于同一个 web 访问 seam。`ctx.web` 为两个并行注册表统一拥有提供方选择、abort/错误词汇和部署配置。它们的请求 schema 和提供方逻辑保持独立;共享的服务是触达 web 的产品边界。 @@ -66,7 +66,7 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性契约和错误码。它不导入 tool、agent、session、LLM 或提供方包。 +`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性契约和错误码。它不导入工具、agent、会话、LLM 或提供方包。 提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 @@ -161,7 +161,7 @@ interface WebService { `max_results` 不暴露给模型。它是 `dsh-tool-web` 层的决策:工具设定结果上限——`searchMaxResults` 插件配置,默认 `8`(与 OpenCode 的 Exa 默认值对齐),类似 `dsh-tool-fs` 的 `readLimit`——并作为 `WebSearchRequest` 上的 `maxResults` 传给 seam。将其排除在模型 schema 之外意味着模型只需提问,产品控制返回多少上下文;该字段日后可以提升为面向模型的参数而不破坏 seam。 -`maxResults` 沿 tool → seam → provider 流动,上限在返回路径上强制执行: +`maxResults` 沿工具 → seam → 提供方流动,上限在返回路径上强制执行: - `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。 - `ctx.web` 将请求原样传递给选定的提供方。 @@ -244,7 +244,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 ## 工具消费方行为 -`dsh-tool-web` 拥有两个 `ToolDefinition`:`web_search` 和 `web_fetch`。它拥有面向模型的 JSON Schema、snake_case 参数名、prompt 段落、结果渲染为 `ContentBlock[]`、`presentCall` 和 `presentResult`。 +`dsh-tool-web` 拥有两个 `ToolDefinition`:`web_search` 和 `web_fetch`。它拥有面向模型的 JSON Schema、snake_case 参数名、提示词段落、结果渲染为 `ContentBlock[]`、`presentCall` 和 `presentResult`。 `dsh-tool-web` 禁止枚举提供方或直接调用提供方的 `available()`。它进入 seam 的唯一路径是 `ctx.web.search()`/`ctx.web.fetch()`。这将提供方选择保持在单一层;否则工具包可能判定某个提供方可用,而执行时解析出不同的状态。 @@ -252,7 +252,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 提供方可用性变化影响执行结果和诊断信息,而非面向模型的 schema 是否存在。如果产品完全不需要 web 工具,在配置中禁用 `dsh-tool-web` 或单个 web 工具即可;如果需要 web 工具但后端配置有误,模型在执行时看到结构化的工具错误。 -prompt 引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——prompt 和工具结果告诉模型用 Markdown 链接引用相关 URL。 +提示词引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——提示词和工具结果告诉模型用 Markdown 链接引用相关 URL。 面向模型的输出以文本为先,因为工具结果是 `ContentBlock[]`,但 seam 的产出保持结构化,以便 UI 展示和未来的适配器无需解析渲染后的文本。 @@ -286,7 +286,7 @@ prompt 引导解释了语义分工——`web_search` 用于发现和获取当前 ### 让每个提供方注册自己的面向模型工具 -这与最灵活的提供方插件系统一致:每个提供方可以暴露其完整的原生 schema。在 harness 中被否决,因为它将面向模型的名称、描述、prompt 引导和结果格式化的所有权交给了提供方包。多个搜索提供方会产生重复的工具名或提供方特有的工具名,模型将学到后端细节而非稳定的产品能力。 +这与最灵活的提供方插件系统一致:每个提供方可以暴露其完整的原生 schema。在 harness 中被否决,因为它将面向模型的名称、描述、提示词引导和结果格式化的所有权交给了提供方包。多个搜索提供方会产生重复的工具名或提供方特有的工具名,模型将学到后端细节而非稳定的产品能力。 ### 将提供方调度直接放在 `dsh-tool-web` 中 @@ -324,7 +324,7 @@ prompt 引导解释了语义分工——`web_search` 用于发现和获取当前 ## 推迟工作 -- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的 prompt),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 +- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 - `pdf` `WebFetchBody` 类别:`local-http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 - 推迟的权限系统落地后的权限策略集成。 diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index 8f3276b131..b7c3eddab3 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-26-file-context-as-event-gate.md: 4700222aa2e0f91d9f355495c228e2eb92825f55 -2026-06-26-file-context-as-event-gate.zh.md: cf490c9f59f1914f1d5d6bdd9fdf4c08711db91e +2026-06-26-file-context-as-event-gate.zh.md: 5edfda5a2da3a7a06093dae65c89fc5ea49b43a8 diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index cf490c9f59..5edfda5a2d 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -110,7 +110,7 @@ interface Events { ## 工具契约(`dsh-tool-fs`) -工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称「后端」要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。 +工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和提示词段落。提示词引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称「后端」要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变提示词立场。 `dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。 @@ -118,7 +118,7 @@ interface Events { 通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat: -- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(快速失败:模型重新读取,从不基于错误版本写入,因为 `editText` 在其锁内重新检查)。 +- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(为安全起见拒绝写入:模型会重新读取;由于 `editText` 会在其锁内复查,模型绝不会基于错误版本写入)。 - **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`,**工具内零 stat**。 - **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。 @@ -165,7 +165,7 @@ interface Events { ## 后果 - **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。 -- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它「只是存储」。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。 +- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它「只是存储」。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不携带面向模型的概念,因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/会话所有者结构。 - **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 Agent Note(可组合的值传递 seam),而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。 -- **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。 +- **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔为安全起见拒绝写入(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。 - **裸提供方不做先读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 710dee1115..2da1519019 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-event-domain-semantics.md: 7310840088d4ff77ddf83c5c16753b4d46256692 -2026-06-30-event-domain-semantics.zh.md: 882779fafa5953eab7048d234429a8a030dc695e +2026-06-30-event-domain-semantics.zh.md: 61d99878ab5e04b74d55d02789c434e0919bd785 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index 882779fafa..61d99878ab 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 事件域语义——session 是事实日志,agent 是运行时表面 +# Agent Note: 事件域语义——会话是事实日志,agent 是运行时表面 Status: implemented @@ -12,7 +12,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 - `tools/*` 承载工具注册表与执行 seam。 -两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)和镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要一个连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听 session 事件还是 agent 事件,以及原因。 +两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)和镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的钩子子系统需要一个连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听会话事件还是 agent 事件,以及原因。 这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code 和 Codex 桥接的基础。 @@ -21,17 +21,17 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **三个域,各司其职,以一条边界规则统一。** - **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。 -- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的 session 事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。 +- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request`、`agent/step-result`、`agent/turn-continuation`)可变更或否决;瞬态 emit(`agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。 - **`tools/*`——工具注册表与执行 seam。** -**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 +**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 **将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP 桥接从 `session/event` 的 `turn/end` 加 `agent/status` 结算;唯一的轮次镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)从 `session/event` 渲染边界,同时保留其实时目标对象用于固定的 `main` 标签。步骤镜像先被移除(它们完全没有消费方);轮次镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 ## 后果 - 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。 -- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 +- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 - 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`。 - Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml index fb07c3c828..74c94e5b4f 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-fs-per-session-cwd.md: aeb17e0a235720bee277f6533468dbdb6cae82dc -2026-07-02-fs-per-session-cwd.zh.md: 34b868761112b3dab31699cd7c945a64e22b7d59 +2026-07-02-fs-per-session-cwd.zh.md: b9ad4b868c472c8feb9e6f65e42b1658ffdcd2e0 diff --git a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md index 34b8687611..b9ad4b868c 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd Agent Note 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。 +ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd`,`dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的每会话 cwd Agent Note 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。 文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。 @@ -34,6 +34,6 @@ ACP(Agent Client Protocol)桥接层为每个会话提供独立的工作区 - 在 ACP 演示中,fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。 - 对于包含 `symlink/..` 的会话 cwd,或普通符号链接 cwd 搭配含父目录遍历的相对路径,bash、文件系统工具和沙箱授权都会从同一个物理工作区解析;词法父目录不会获得授权。 -- `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的 per-session cwd 产生与 bash 目标相同的 key。 +- `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的每会话 cwd 产生与 bash 目标相同的 key。 - 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。 - 单会话 stdio 演示不受影响:它不提供会话 cwd(其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 10be0c15fe..c4e769f684 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-tool-render-intent-union.md: c7adf8f405000ec7940f82e1e1e2406d86253461 -2026-07-02-tool-render-intent-union.zh.md: 9e9248b95acbbf9d197322acd2f38f594aef3bf1 +2026-07-02-tool-render-intent-union.zh.md: 01dff0daaacc45f5c85eb2c4380dc78800ce9a81 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index 9e9248b95a..01dff0daaa 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -12,7 +12,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + snapshot 回放路径)。 +`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash`、`dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + 快照回放路径)。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml index 1f09f82d11..0a8c0d62fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-03-filesystem-directory-listing-seam.md: c7db576ff3c7a56622f90a4400bd9297c9591bef -2026-07-03-filesystem-directory-listing-seam.zh.md: 44996d4c680bb2a0c3e880a120e0f9bd420493cd +2026-07-03-filesystem-directory-listing-seam.zh.md: 75ee6851127ca6d8c3fc60a66115d521d4627cdc diff --git a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md index 44996d4c68..75ee685112 100644 --- a/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md @@ -10,7 +10,7 @@ Status: implemented 直接的压力来自 skill(技能)加载:读取单个 `SKILL.md` 已经可以走 `ctx.get('fs')`,但发现哪些 skill 根目录包含 `<name>/SKILL.md` 或 `<name>.md` 仍需要目录枚举。如果仅在 `dsh-skill` 中添加目录列举,要么保留对 Node 的直接依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。 -本决策只添加提供方能力,不涉及面向模型的 `ls`/`list` 工具或 skill 发现机制的变更。那些消费方需要独立的 UX、prompt 与策略决策。 +本决策只添加提供方能力,不涉及面向模型的 `ls`/`list` 工具或 skill 发现机制的变更。那些消费方需要独立的 UX、提示词与策略决策。 ## 决策 @@ -26,7 +26,7 @@ Status: implemented 它从不读取文件内容。递归遍历、glob 匹配、分页、搜索、文件监听和面向模型的渲染均有意不在范围内。 -本地后端通过 `readdir({ withFileTypes: true })`、`resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的 prompt/列表输出稳定,并提高前缀缓存复用率。 +本地后端通过 `readdir({ withFileTypes: true })`、`resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的提示词/列表输出稳定,并提高前缀缓存复用率。 损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。在列举目录或解析/探测子项元数据时遇到权限或后端 I/O 故障,则以结构化的 `FsError` 错误码使整个列举失败: @@ -38,7 +38,7 @@ Status: implemented ## 曾考虑的替代方案 -**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其 prompt、schema 和渲染契约与提供方原语相互独立。 +**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其提示词、schema 和渲染契约与提供方原语相互独立。 **让每个消费方自行枚举目录。** 否决。这会将 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 109fbae933..1f232965a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 572a95c34381f6d73d1f6e053e565f30704026d3 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: f1379e143a94a3ae3a07b3120c6f0b9fc8561fe9 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 572a95c343..f1379e143a 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Prompt 变量与工具指导归属 +# Agent Note: 提示词变量与工具指导归属 Status: implemented @@ -8,7 +8,7 @@ Status: implemented 组装后的系统提示词存在四个缺陷,同属一类:harness 已知的事实在别处被手工重述,然后漂移。 -**模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何 prompt 文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 +**模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 **工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。 @@ -18,55 +18,55 @@ Status: implemented ## 决策 -**一条原则:prompt 中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包(package)的 prompt section。harness 来源标识 → 静态的 `harness:identity` section。部署角色与行为 → 部署的 persona。 +**一条原则:提示词中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量,persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包(package)的提示词 section。harness 来源标识 → 静态的 `harness:identity` section。部署角色与行为 → 部署的 persona。 ### 组装上下文 `SystemPrompt.assemble(context)` 接受一个可合并扩展的 `AssembleContext`。`dsh-system-prompt` 声明可选的 `scope` 选择器用于 scoped 路由,而 `dsh-agent` 通过声明合并将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖循环)。循环在每个步骤调用 `assembleContextFor(agent)`,使两个字段标识同一个 agent;section 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall(瀑布式事件)也接收它,监听器可据此按 agent 过滤或扩展。 -### Prompt 变量 +### 提示词变量 -插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用了未知的 own-property、已注册的 provider 返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 +插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用了未知的 own-property、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 `dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。 ### Persona 作为 order-0 section -`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的 prompt,稍后由 `ctx.tokenMeter` 为压缩压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 +`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的提示词,稍后由 `ctx.tokenMeter` 为压缩压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 ### 工具指导归属 -每个工具的语义和选择指导放在工具 description 中。prompt section 只承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的 description 包含完整契约。部署 persona 只包含角色和行为。 +每个工具的语义和选择指导放在工具 description 中。提示词 section 只承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section,因为它们的 description 包含完整契约。部署 persona 只包含角色和行为。 ### Subagent 对话历史描述符 -`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 +`SubagentProvider.inheritsParentContext` 描述的是对话种子,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具和提示词参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 ## 曾考虑的替代方案 - **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) -- **通过 `agent/request` waterfall 注入模型名称**:prompt 文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在 prompt 中更早出现的声明。 +- **通过 `agent/request` waterfall 注入模型名称**:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。 - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 Agent Note 要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 -- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**:`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。 -- **在 `apply` 时解析 provider(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:provider 生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 +- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 +- **在 `apply` 时解析提供方(加载顺序要求)** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 ## 不在范围内 - 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 Agent Note 不认领任何一个。 -- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到 session-cwd 方案重新讨论时。 +- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到会话 cwd 方案重新讨论时。 ## 交付的不变式 -- tui-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 -- fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。 +- tui-agent 的提示词通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/bash/web 指导。 +- fork 和 fresh subagent 的描述反映提供方是否继承已完成的对话轮次;工具随提供方生命周期变化而出现、消失和重新措辞。 - 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 -- 快照回放与 prompt 无关:它按轮次和步骤索引已记录的 chunk 流,不比较发出的请求。 +- 快照回放与提示词无关:它按轮次和步骤索引已记录的分片流,不比较发出的请求。 ## 后果 -- 组装后的 prompt 中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 -- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,prompt 对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 -- 当一个已绑定的 provider 不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 +- 组装后的提示词中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。 +- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 +- 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 - 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望大声暴露的撰写错误。 -- 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,再行添加。 +- 目前没有在提示词行文中转义字面 `{{name}}` 的语法;如果真实提示词确实需要,再行添加。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 7f8ecda72d..f9c309c39f 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-reconstructable-requests.md: 153d37a2faf2265134d5ff9e88f0bbfa275328e0 -2026-07-05-reconstructable-requests.zh.md: c14738f8a2bc8fd25b66a3b3f82b650dd52231c1 +2026-07-05-reconstructable-requests.zh.md: caf51c3065e416fd11aebc1c1d4dc2ee248e2e5c diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index c14738f8a2..caf51c3065 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -24,11 +24,11 @@ Status: implemented `EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个步骤重建 prompt 组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果在通用 `agent/pre-step` 检查点与边界快照之前被冻结并缓存于该循环实例。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。 +每个步骤重建提示词组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果在通用 `agent/pre-step` 检查点与边界快照之前被冻结并缓存于该循环实例。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。 **`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step(agent, turn, step, signal)` 仍是当前请求所需内容的通用 seam。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 -**强制执行。** `dsh-agent-loop/invariant` 配套插件向 `ctx.invariants` 注册,并在被选用时通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环通过 `dsh-llm` 的 `markAgentLoopRequest()` 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或 session id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。 +**强制执行。** `dsh-agent-loop/invariant` 配套插件向 `ctx.invariants` 注册,并在被选用时通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环通过 `dsh-llm` 的 `markAgentLoopRequest()` 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或会话 id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。 ### MiniCode 形态:采纳,但溯源箭头反转 @@ -48,9 +48,9 @@ Status: implemented - 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。 - 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()` 以及工具/prompt-submit 的 `additionalContexts`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。 -- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目)、真正的 prompt、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 +- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。 - 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 -- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对 chunk 密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 -- 快照 expected output 变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 +- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 +- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 - FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(reasoning 选项、额外 body 参数)应归属何处。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 732d69205f..516dd4edcb 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-subagent-provider-lifecycle-events.md: afd45027e8b56cbf1d17e6dec749d8602c81124d -2026-07-05-subagent-provider-lifecycle-events.zh.md: 98bcaed31d88c0b6c10ffd7942e77f8048733302 +2026-07-05-subagent-provider-lifecycle-events.zh.md: 58d439936a3f2cc51d8190cbebe8e68cdb14c855 diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index 98bcaed31d..58d439936a 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[prompt-variables Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn/ACP 为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.md))。 @@ -25,12 +25,12 @@ Status: implemented - **在 `apply` 时解析提供方,不存在则抛异常**:否决。「先列后端」这一要求声称了 Loader 并不存在的顺序保证。 - **重试查找(轮询直到提供方出现)**:最终能收敛,但在框架已有的机制(effect 注册 + disposal)之外发明了一套私有就绪协议;它也无法感知提供方离开,因此 HMR 会遗留一个措辞描述已 dispose 后端的工具。 -- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但将 tool-choice 引导移出了描述,与 prompt-variables Agent Note 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 +- **仅在 section 中放置 subagent 措辞,在组装时惰性解析**:同样能容忍任意加载顺序,但将 tool-choice 引导移出了描述,与提示词变量 Agent Note 建立的所有权规则相矛盾(每个工具的语义和何时使用属于描述)。响应式注册既保持描述的权威性,又不依赖顺序。 - **根据提供方名称而非提供方对象确定措辞**:`providerName` 本身是配置,重命名后的提供方会静默获得错误的措辞;从已解析提供方自身的 `inheritsParentContext` 派生则不会漂移。 ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 - **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/cordis-catalog/events.md)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。 -- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持 prompt 组装的时效性。 +- **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表的 `tools/change` 事件发出会保持提示词组装的时效性。 - **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例命名了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index fe015dff1b..941a502c73 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 -2026-07-06-timeout-deadline-library.zh.md: c6e5706e9a62877dc71d56951e82bc892b02a75e +2026-07-06-timeout-deadline-library.zh.md: 6ac9d684582e04d5c7c21a74692fb6452a2846dd diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index c6e5706e9a..6ac9d68458 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -92,7 +92,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码契约不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 - **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 -- **LLM 适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在 chunk 之间花费的时间。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 +- **LLM 适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在分片之间花费的时间。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 ## 后果 @@ -102,11 +102,11 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 - 模型流现在共享一个可重启的定时器契约,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 -以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其 tool-schema/snapshot 覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 +以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema/快照覆盖率规划就绪后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在存在后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 ## 曾考虑的替代方案 -**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和 model-stream 各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 +**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和模型流各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 **每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得一个小型共享原语严格更优,因此成本/收益不同。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 63e93dd74a..ae6611d586 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 -2026-07-08-agent-scope-contexts.zh.md: ff8243ebdd541e4a7821d9c4ff99217a8060c2b4 +2026-07-08-agent-scope-contexts.zh.md: 81decfe54c1a13ac9f8361fb98c94804bb6b80cc diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index ff8243ebdd..81decfe54c 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -14,9 +14,9 @@ Status: implemented ## 决策 -每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的 context 进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 +每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 -Cordis 是 SDK 底层的插件框架。Cordis **context** 是插件用来访问服务和注册效果的对象,效果的清理跟随该 context。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。 +Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。 对大多数贡献者而言,完整契约是四条规则: @@ -49,11 +49,11 @@ flowchart LR ### 注册来源决定可见性与清理 -通过普通插件 context 进行的注册是部署全局的,随该插件一起 dispose(资源释放)。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域一起 dispose。 +通过普通插件上下文进行的注册是部署全局的,随该插件一起 dispose(资源释放)。同一方法通过 `agent.ctx` 调用则贡献给一个 agent,随该 agent 的作用域一起 dispose。 | 注册来源 | 默认可见性 | 随谁 dispose | |---|---|---| -| 普通插件 context | 每个符合条件的 agent 视图 | 注册插件 | +| 普通插件上下文 | 每个符合条件的 agent 视图 | 注册插件 | | `agent.ctx` | 仅该 agent 的视图 | agent 作用域 | 工具、提示词段落与变量、工具限制、守卫以及作用域事件监听器都遵循此契约。命名的本地值通常对该 agent 遮蔽同名全局值;各所属服务文档会说明例外与合并行为。 @@ -88,7 +88,7 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -setup 接收一个完整的受信 Cordis context,因此可以组合普通插件和服务。其契约仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 +setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插件和服务。其契约仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 ### 操作选择视图 @@ -104,19 +104,19 @@ setup 接收一个完整的受信 Cordis context,因此可以组合普通插 在 Cordis 层面,`Scoped<T>` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 -以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册 context。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../../docs/cordis-catalog/events.md)是详尽的事件参考。 +以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。生成的[事件目录](../../../../docs/cordis-catalog/events.md)是详尽的事件参考。 ### 创建最后发布,dispose 最后撤销 `ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。 -可选的创建信号仅在 create 或 resume 挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。 +可选的创建信号仅在创建或恢复挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。 如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 `AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷写,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 -调用方的 Cordis context 和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。 +调用方的 Cordis 上下文和具体的 AgentLoop 工厂是结构性共同所有者。卸载任一方都会 dispose 事务或存活 agent。 ```mermaid flowchart TB @@ -140,7 +140,7 @@ flowchart TB agent 作用域组合的是受信的同进程注册。它不沙箱化插件、不定义父到子的权限格、不在创建时冻结授权、也不保证子级不能做超出父级的事。 -父级可以拥有一个可见工具比自身更广的子级,因为生命周期所有权不赠予也不限制注册。持有 Cordis context 的插件同样运行在同一进程中,可以直接调用可用服务。 +父级可以拥有一个可见工具比自身更广的子级,因为生命周期所有权不赠予也不限制注册。持有 Cordis 上下文的插件同样运行在同一进程中,可以直接调用可用服务。 需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。 @@ -166,6 +166,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 ## 后果 -贡献者使用一种熟悉的模式:通过插件 context 注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 +贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限,subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 679c1fd240..1f09afec2f 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-agent-scope-runtime-design.md: cf42bbacbfb9d2fcbd72aafba5ea02ddf83e1ce5 -2026-07-12-agent-scope-runtime-design.zh.md: 56a3d3b47d651115c6f5f215849712607ae41bca +2026-07-12-agent-scope-runtime-design.zh.md: 3d19aa42a7475f01070fbe375b386e782b5f81b7 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 56a3d3b47d..3d19aa42a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体与共享 layer store;每个活跃的注册表对象有一条注册表条目;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式 prompt 组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和完全停稳态。 +运行时对每个独立事实使用一种机制。作用域路由有一个不透明载体与共享 layer store;每个活跃的注册表对象有一条注册表条目;每个创建或恢复操作有一个事务;类型化的同进程调用借用 readonly 值;真实数据边界只物化一次;协作式提示词组装的结果即为权威;worker/进程代码仅在不同所有者确实可能竞争时才保留独立的终止态和完全停稳态。 该设计可概括为七项选择: @@ -25,26 +25,26 @@ Status: implemented | 协调创建/恢复 | 单个 `AgentCreationTransaction` | | 保护持久化、队列、模型或协议格式数据 | 在该边界处一次性物化 | | 在同一进程内传递类型化值 | Readonly 借用契约 | -| 组合模型可见的 prompt 与工具表面 | 单个共享工具视图加权威的 assembly-waterfall 结果 | +| 组合模型可见的提示词与工具表面 | 单个共享工具视图加权威的 assembly-waterfall 结果 | | 协调 subagent、worker 和进程关闭 | 单个取消信号加该边界独立的终止态/完全停稳态事实 | -本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与 prompt、subagent 与工作流,最后是可执行检查。 +本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与提示词、subagent 与工作流,最后是可执行检查。 [7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md)仍然是贡献者契约。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 -## Cordis 模型:context、fiber、effect、receiver 与 waterfall +## Cordis 模型:上下文、fiber、effect、receiver 与 waterfall -理解实现需要五个 Cordis 概念。Context 选择服务和注册所有权;fiber 是一个活跃的插件或子生命周期;effect 将清理逻辑附加到 fiber;event receiver 选择监听器;waterfall(瀑布式事件)让监听器按顺序变换或否决一个操作。 +理解实现需要五个 Cordis 概念。上下文选择服务和注册所有权;fiber 是一个活跃的插件或子生命周期;effect 将清理逻辑附加到 fiber;事件接收器选择监听器;waterfall(瀑布式事件)让监听器按顺序变换或否决一个操作。 -### Context 是贯穿单个服务图的所有权路径 +### 上下文是贯穿单个服务图的所有权路径 -所有 agent 共享一个 Cordis 服务图。派生的 context 不会克隆 `ToolRegistry`、`SystemPrompt`、持久化或模型适配器;它改变的是:通过该 context 进行的注册如何被标记,以及哪些 effect 拥有其清理逻辑。 +所有 agent 共享一个 Cordis 服务图。派生的上下文不会克隆 `ToolRegistry`、`SystemPrompt`、持久化或模型适配器;它改变的是:通过该上下文进行的注册如何被标记,以及哪些 effect 拥有其清理逻辑。 -`agent.ctx` 就是这样一个派生 context。服务调用仍然到达共享实例,而注册操作可以检查其调用 context 并将贡献存储在最近的作用域键下。普通的插件 context 不携带作用域键,因此注册到全局。 +`agent.ctx` 就是这样一个派生上下文。服务调用仍然到达共享实例,而注册操作可以检查其调用上下文并将贡献存储在最近的作用域键下。普通的插件上下文不携带作用域键,因此注册到全局。 ### Fiber 与 effect 使清理成为结构性的 -Cordis fiber 是插件或子 context 被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该 context 注册的一切,无需单独的清单。 +Cordis fiber 是插件或子上下文被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该上下文注册的一切,无需单独的清单。 vendor 中的 Cordis fiber 实现在任意 setup 或 `internal/plugin` 观察者运行之前就建立了所有权。可重入的卸载可以看到已启动的子 fiber 或 effect,拒绝卸载开始后添加的 effect,并通过一个公开的一次性 disposer 加入已启动的清理。拆除观察者被逐个隔离,因此一个回调无法阻止结构性清理。 @@ -56,7 +56,7 @@ Cordis 使用 dispatch receiver(`this`)过滤监听器,而 harness 的监 因此,产品辅助函数构造载体并单独传递领域主体。这防止监听器路由变成另一套对象模型,并使事件签名在不了解载体内部的情况下也可理解。 -Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()`:调用它则委托给剩余监听器和基础操作,不调用则否决或替换下游结果。Waterfall 驱动 prompt 组装和工具策略;普通 emit 事件同步通知,parallel 事件等待所有监听器但没有否决结果。 +Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()`:调用它则委托给剩余监听器和基础操作,不调用则否决或替换下游结果。Waterfall 驱动提示词组装和工具策略;普通 emit 事件同步通知,parallel 事件等待所有监听器但没有否决结果。 ## 作用域路由:一个不透明键选择一层 @@ -66,19 +66,19 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个 `ScopeKey` 是一个按标识比较的不透明对象。Harness 使用活跃的 `Agent` 作为自身的键,但该原语与领域无关,支持其他作用域所有者。 -`createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建 event receiver,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。 +`createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建事件接收器,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。 Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。 ### 注册表读取叠加一个精确 layer -作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个 context 派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。 +作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。 -每个服务保留其领域规则。命名 command 和 prompt 视图使用共享的、保持插入顺序的 shadow merge;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。Prompt 变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 +每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 ### 融合 dispatch 辅助函数防止主体漂移 -`agentEvents(context, agent)` 构造 agent 的载体并注入同一个 agent 作为事件主体。Session、tool、approval、prompt 和 subagent 服务同样从它们已拥有的对象派生路由,而非接受一个无关的键。 +`agentEvents(context, agent)` 构造 agent 的载体并注入同一个 agent 作为事件主体。会话、工具、approval、提示词和 subagent 服务同样从它们已拥有的对象派生路由,而非接受一个无关的键。 类型标记拒绝普通的裸 receiver 误用,开发环境不变式覆盖直接 JavaScript 或强制转换的 dispatch。主体保持显式,因为路由正确性和有用的事件数据是不同的关注点。 @@ -96,7 +96,7 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册 ### 事务在等待之前就拥有准备工作 -事务在持久化加载或 setup 可能挂起之前,就被安装到调用方的 Cordis context 和具体的 AgentLoop 工厂下。它还在公开操作结算之前观察可选的创建/恢复信号。 +事务在持久化加载或 setup 可能挂起之前,就被安装到调用方的 Cordis 上下文和具体的 AgentLoop 工厂下。它还在公开操作结算之前观察可选的创建/恢复信号。 创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。 @@ -104,7 +104,7 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册 ### Setup 是私有世界内的可信组合 -Setup 接收完整的子 context,可以等待插件激活。它可以注册工具、prompt 段、限制、监听器和其他 effect,但公开契约不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 +Setup 接收完整的子上下文,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect,但公开契约不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 事务将异步加载和 setup 与停用进行竞争,而非无限等待外部代码拥有的 promise。如果取消或所有者卸载获胜,即使外部 promise 永不结算,公开创建也会在事务拥有的清理之后拒绝。 @@ -112,7 +112,7 @@ Setup 接收完整的子 context,可以等待插件激活。它可以注册工 发布按观察者所需的顺序接纳和宣告资源: -1. 将 session 写入注册表。 +1. 将会话写入注册表。 2. 将 agent 写入注册表。 3. 宣告 `session/created`。 4. 宣告 `agent/created`。 @@ -152,11 +152,11 @@ sequenceDiagram 1. 停用创建或驱动,让同步发布完成。 2. 停止并排空 driver,包括空闲注入刷新。 3. 分离 agent。 -4. 分离 session。 +4. 分离会话。 5. Dispose agent 作用域。 6. 退役事务所有权追踪。 -此顺序让最终的 agent 和 session 事件能使用匹配的作用域监听器,并使持久化观察者在最终刷新完成前保持附加。作用域 dispose 放在最后,因为注册撤销是外部可见的生命期边界。 +此顺序让最终的 agent 和会话事件能使用匹配的作用域监听器,并使持久化观察者在最终刷新完成前保持附加。作用域 dispose 放在最后,因为注册撤销是外部可见的生命期边界。 ## 会话追加:物化、验证、提交、通知 @@ -200,9 +200,9 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 回调隔离与数据所有权是分开的。监听器是任意扩展代码,即使其参数是可信的也可能抛出异常;发布和提交后路径仍按其事件契约隔离失败。 -## 工具与 prompt:单一视图、权威组装、已提交的结果 +## 工具与提示词:单一视图、权威组装、已提交的结果 -工具展示和执行共享一个私有解析器。Prompt 组装仍然是可信的协作式组合:注册表提供有序输入,assembly waterfall 的返回值就是 agent loop(智能体循环)记录和发送的内容。执行仅在策略或结果结算必须单调时才使用独立的单向边界。 +工具展示和执行共享一个私有解析器。提示词组装仍然是可信的协作式组合:注册表提供有序输入,assembly waterfall 的返回值就是 agent loop(智能体循环)记录和发送的内容。执行仅在策略或结果结算必须单调时才使用独立的单向边界。 ### 一个解析器定义工具视图 @@ -224,7 +224,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 ### Assembly waterfall 拥有最终的模型可见组合 -SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为确定性的注册表贡献。作用域过滤的 `system-prompt/assemble` waterfall 随后可以重排、替换、添加或移除任何段、变量或 schema。其返回的组装结果即为权威;没有后续的恢复步骤,普通 prompt 段、工具定义或提供方结果上也没有终态元数据。 +SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为确定性的注册表贡献。作用域过滤的 `system-prompt/assemble` waterfall 随后可以重排、替换、添加或移除任何段、变量或 schema。其返回的组装结果即为权威;没有后续的恢复步骤,普通提示词段、工具定义或提供方结果上也没有终态元数据。 这是一个可信的同进程扩展 seam,而非权限边界。修改 Code Mode 的 `run_code` schema 或 `tools:sdk` 指令,或结构化子级的捕获 schema 或指令的监听器,有责任在其返回的组装中保持协议的一致性。ToolRegistry 仍然保留 `run_code` 不受普通工具注册和限制影响,因为那些是注册表不变式,但 assembly 中间件仍然可以自由变换最终的模型可见表面。 @@ -244,7 +244,7 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 ### 三个执行边界有意设为单向 -Prompt 组装有意是协作式的,但三个执行事实在其可扩展阶段之后需要单向结算: +提示词组装有意是协作式的,但三个执行事实在其可扩展阶段之后需要单向结算: | 边界 | 最终权力 | 为何普通监听器顺序不够 | |---|---|---| @@ -286,7 +286,7 @@ Spawn 使用空会话种子。Fork 使用经验证的已完成轮次前缀。对 ACP 提供方跨越真实的进程和协议格式边界,因此它保留验证、环境清洗、消息序列化、abort/进程竞争,以及从 kill 到进程退出并完全停稳的过程。 -Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn 失败、RPC 失败或无效启动响应在拒绝前回收进程。就绪后,result 映射 ACP prompt 结果和流式输出;dispose 请求取消、关闭连接并通过一条记忆化路径等待进程退出。 +Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn 失败、RPC 失败或无效启动响应在拒绝前回收进程。就绪后,result 映射 ACP 提示词结果和流式输出;dispose 请求取消、关闭连接并通过一条记忆化路径等待进程退出。 ## 工作流与 ACP UI:仅保留独立的异步事实 @@ -306,11 +306,11 @@ Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲 公开 dispose 在调用回调之前取得其记忆化 promise 的所有权。Worker 死亡在处理任何排队的迟到子级请求之前关闭准入,合成缺失的生命周期结束,并启动子级/进程清理而不重写已声明的结果。 -### ACP prompt 结算不依赖渲染成功 +### ACP 提示词结算不依赖渲染成功 -ACP UI 直接将 prompt 与其观察到的轮次关联。它不从 `logWatermark` 扫描,也不使用会话状态作为第二个调和预言机。 +ACP UI 直接将提示词与其观察到的轮次关联。它不从 `logWatermark` 扫描,也不使用会话状态作为第二个调和预言机。 -Prompt 处理在 transcript(文本记录)渲染的 `finally` 中结算关联。渲染失败可以导致展示失败,但不能跳过 prompt 结算或让会话永久处于进行中状态。对同一持久化的调用方提供的会话 ID 的并发加载仍被排除,因为那是真实的持久化标识竞争,而非 UUID 碰撞问题。 +提示词处理在 transcript(文本记录)渲染的 `finally` 中结算关联。渲染失败可以导致展示失败,但不能跳过提示词结算或让会话永久处于进行中状态。对同一持久化的调用方提供的会话 ID 的并发加载仍被排除,因为那是真实的持久化标识竞争,而非 UUID 碰撞问题。 ## 正确性强制 @@ -318,7 +318,7 @@ Prompt 处理在 transcript(文本记录)渲染的 `finally` 中结算关联 ### 类型使常规路径难以误用 -Readonly 契约描述借用的同进程值。`Scoped<T>` 标记 event receiver,`agentEvents()` 融合载体和主体,工具输入省略注册表拥有的 token,subagent 异步返回类型直接暴露就绪性。 +Readonly 契约描述借用的同进程值。`Scoped<T>` 标记事件接收器,`agentEvents()` 融合载体和主体,工具输入省略注册表拥有的 token,subagent 异步返回类型直接暴露就绪性。 TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进程消息或持久化文件,因此运行时强制保留在这些逃逸点。 @@ -326,13 +326,13 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 `dsh-scope/invariant` 配套插件在被选用时验证每个声明的作用域事件使用带标记的载体,以及暴露主体的事件族使用匹配的键。独立的 `dsh-session/invariant` 贡献在追加提交前暂存 trace 验证,并在同一事件提交后推进;二者都通过 `ctx.invariants` 注册。 -该插件不通过扫描注册表来管控可信 setup,也不拒绝通过强制转换构造的 prompt assembly 对象。这些检查会将组合契约变成推测性的运行时机制,却不保护真实的外部边界。 +该插件不通过扫描注册表来管控可信 setup,也不拒绝通过强制转换构造的提示词 assembly 对象。这些检查会将组合契约变成推测性的运行时机制,却不保护真实的外部边界。 ### 生成的产物使公开契约保持对齐 事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md)拥有 Program 构造、语义事件发现和解析器生成规则。 -行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式 prompt 组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 +行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 @@ -342,7 +342,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 模拟主体的代理必须保持属性、可调用、可构造、私有字段、描述符和代理不变式行为,而监听器路由从不需要这些。一个小型不透明载体保持过滤器和键,而显式事件参数携带主体。 -### 在 setup 前预留 agent 和 session ID +### 在 setup 前预留 agent 和会话 ID 预留防止重复的私有 setup 工作,但需要跨服务能力、释放排序、废弃预留清理和已准备对象绑定。ID 由调用方提供,并发复用是调用方错误;最终写入注册表时可以选择赢家,而失败的事务干净地回滚。 @@ -358,7 +358,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 这将提供方接受与就绪分离,迫使每个消费方注册部分 run、附加结果观察、等待就绪并清理就绪失败。异步 start promise 使提供方到调用方的所有权转移本身成为就绪边界。 -### 在 assembly 之后恢复选定的 prompt 或工具贡献 +### 在 assembly 之后恢复选定的提示词或工具贡献 Waterfall 之后的恢复步骤会在文档化的协作式 seam 之后创建第二套组合规则。正确分配规范的存在或缺失还需要为任意工具 schema 提供方制定所有权和碰撞规则,而这些提供方的普通输出可能包含重复名称。作用域注册已经提供了所需的按 agent 隔离,可信的 assembly 监听器拥有其返回内容的协议一致性,因此命名恢复增加了机制却不建立独立边界。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml index 56f3e87525..6ebcd37096 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml @@ -3,4 +3,4 @@ # 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: 2bc6f71a8d370d65ccc3624724528a99f452bd9f +2026-06-14-acp-agent-client-protocol.zh.md: 180a199ec0d0fca5d9deba84d8c31ca63426c519 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md index 2bc6f71a8d..180a199ec0 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联 prompt 完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 +harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联提示词完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。 @@ -16,23 +16,23 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本 桥接层实现以下稳定的会话路径: -- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的 prompt,并声明 `loadSession` 能力。 +- `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 prompt,并在该 prompt 所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。 -- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的 prompt。 +- `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 保持 fail-closed。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。 +权限处理是[用户审批 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(资源释放)会取消待处理的 prompt,并行 dispose 所有 handle,等待循环静默与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。 +生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `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);package README 是操作契约。 +精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);包 README 是操作契约。 ## 曾考虑的替代方案 @@ -48,12 +48,12 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本 ## 后果 -编辑器可以通过一条 ACP 连接创建、加载、提交 prompt、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、prompt 结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。 +编辑器可以通过一条 ACP 连接创建、加载、提交提示词、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、提示词结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。 -桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源 prompt、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。 +桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源提示词、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。 空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。 ## 验证 -ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的 prompt 结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放静默,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。 +ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的提示词结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放静默,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml index 0b5380e4d4..4969861f15 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml @@ -3,4 +3,4 @@ # 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: 012752273b469295fcf8338f809dca252fc377ac +2026-06-14-acp-multi-session.zh.md: 6835f9623b89dc20d6c7b3054365cd9c39402aeb diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md index 012752273b..6835f9623b 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -6,19 +6,19 @@ Status: implemented ## 问题 -一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上保持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个 session id 和并发加载。多路复用引入了隔离风险:事件、prompt 完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。 +一个 ACP(Agent Client Protocol)编辑器可以在同一个 agent(智能体)子进程上保持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个会话 id 和并发加载。多路复用引入了隔离风险:事件、提示词完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。 ## 决策 -ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent 句柄、进行中的 prompt、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。 +ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent 句柄、进行中的提示词、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载。 -每个 `session/event` 和 `agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的 prompt。prompt 记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的 prompt。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。 +每个 `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,将每个待处理的 prompt 以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。 +连接拆除时清空活跃 map,将每个待处理的提示词以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。 ## 协议与工作区作用域 @@ -34,14 +34,14 @@ ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agen **每会话 `ctx.extend()`**:否决。子上下文本身不会创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话拥有的记录;agent 生命周期由 `AgentHandle` 管理。 -**以 Agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的 session token 才是跨边界的标识,应当在插件重载后仍然存活。 +**以 Agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的会话 token 才是跨边界的标识,应当在插件重载后仍然存活。 ## 后果 -N 个会话可以并发地进行流式输出、prompt、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。 +N 个会话可以并发地进行流式输出、提示词、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。 桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于延期状态。 ## 验证 -多会话测试套件通过交错更新、独立的进行中 prompt、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离以及拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。 +多会话测试套件通过交错更新、独立的进行中提示词、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离以及拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 1b534f53a3..3731c58129 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -3,4 +3,4 @@ # 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: 88fe834fdc2c0be63109dbe6dcf31859d2bc0524 +2026-06-15-code-mode.zh.md: cbc164bb4e4de9752cae4161326cb52c7d841e8b diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 88fe834fdc..cbc164bb4e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -18,9 +18,9 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 三项决策,各自在下方独立小节中展开: -1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式 prompt 组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 +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 后执行,绑定通过 message port 桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 +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` 和无损外层输出边界。 @@ -28,11 +28,11 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 `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` 是一个保留的呈现传输通道,位于注册和限制层之外;直接 prompt 提供方和组装 waterfall 仍各自负责自己的贡献。 +**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'code'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。 **与 `toolOrder` 的交互,预先说明:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。 -**SDK prompt 段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 +**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 会覆盖有意的组合。 @@ -46,7 +46,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 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 block 会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 +**子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 **并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。 @@ -75,7 +75,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 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. **通过 message port 桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。 +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)。 @@ -94,7 +94,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw ## 测试 - **Worker 运行时:** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。 -- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层 block 抑制以及 HMR(热模块替换)清理。 +- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层阻止抑制以及 HMR(热模块替换)清理。 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 - **快照:** `code-mode-turn`、`both-mode-turn` 和 `code-mode-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 @@ -122,7 +122,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw **`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。 -**SDK 的 prompt 成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 +**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` 之后。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index afe78658ef..7998dc3257 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-compaction-capability-seam.md: a263b5e7d0245bd1279024a50e05b2f33edad521 -2026-06-18-compaction-capability-seam.zh.md: b86393332592bd92f6e6be81d610370c8c2bb23e +2026-06-18-compaction-capability-seam.zh.md: df0cf9d9131978e608d47124ba0f0db0343ee12a diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index b863933325..df0cf9d913 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -105,7 +105,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab `compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。 -**核心 session 修复保持对压缩无感知——这是有意为之。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。由于仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。 +**核心会话修复保持对压缩无感知——这是有意为之。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。由于仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。 ## 曾考虑的替代方案 @@ -119,7 +119,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **包**:`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`** 通过声明合并(merge-extensible)获得 `compact/start` / `compact/summary` / `compact/end`;`SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。 +- **`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`;服务级默认值使组合无需重复数值策略即可使用。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 35b3e3032b..7ecfa05391 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47 -2026-06-21-subagent-capability-seam.zh.md: 9cab992e86120dd32cedea05b63c777b8678d409 +2026-06-21-subagent-capability-seam.zh.md: 9386b495a03611ecbf1d45e53c8ba0aa87eaa6f0 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 9cab992e86..9386b495a0 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -20,7 +20,7 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智 ### 为何不采用 bash seam 的形状 -bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在每个 context 中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。 +bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在每个上下文中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。 ## 决策 @@ -59,7 +59,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 提供方选择是配置,不面向模型 -`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——本版 schema 中没有 provider/type 参数。 +`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——本版 schema 中没有提供方/type 参数。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 6132be4521..64d1f57f51 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -3,4 +3,4 @@ # 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: f313c11b22dc6aae6d1d1e72fce2381a572ded8d +2026-06-22-acp-subagent-backend.zh.md: 3b6a11efc1ae0427eb5ce3b30029b77b00d6801a diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index f313c11b22..3b6a11efc1 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -38,7 +38,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 测试 -- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试 prompt/output 流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、pre-session 竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。 +- **无需密钥的单元/集成测试:** 一个脚本化的 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 的回放支持。 @@ -59,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ## 后续提供方 -同样的进程外 spawn/prompt/stream/cancel 形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 +同样的进程外启动/提示词/流式输出/取消形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。 diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml index ac47c1ea9e..cd85b98708 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml @@ -3,4 +3,4 @@ # 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: 48d5ace1192071d8b2f0017042ae21fa938ada48 +2026-06-25-ask-user-question.zh.md: eb166acfef7ed8f7ab4eb3018bf161a9ecc3549e diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md index 48d5ace119..eb166acfef 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md +++ b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md @@ -12,11 +12,11 @@ agent(智能体)有时仅凭模型推理(inference)无法安全地继续 ## 决策 -引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与面向模型的消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之的:向人类提问是一种由 UI 支撑的产品功能,不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品界面提供收集答案的具体 provider。该工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将 provider 计算出的结构化答案作为工具结果返回。 +引入 `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` 别名。 -Provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形状。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖任何已选择的选项,`selected` 为空。支持部分完成的提供方使用现有的 `{ id, selected: [] }` 形状表示某项被有意跳过,在不扩展工具结果词汇的前提下保留其他答案。 +提供方返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形状。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖任何已选择的选项,`selected` 为空。支持部分完成的提供方使用现有的 `{ id, selected: [] }` 形状表示某项被有意跳过,在不扩展工具结果词汇的前提下保留其他答案。 `UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER`、`ASK_ABORTED`、ACP(Agent Client Protocol)取消或会话路由缺失等失败会以机器可路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能够区分「用户取消」与一般的抛出异常。 @@ -26,7 +26,7 @@ Provider 返回 `{ answers: [{ id, selected, custom? }] }`。`selected` 始终 Web 输入区一次显示一个问题,同时在会话对象层保留每个请求。它支持单选、多选、无选项问题或显式自定义答案、描述文本与可视化推荐标记,但不会自动选中推荐项。选择单选项后会立即进入下一项;当所有项都已回答或显式跳过时,按 Enter 提交;IME 组字期间按 Enter 只会确认输入候选项。页脚只跳过当前项并保留先前的草稿;关闭控件以 `ASK_CANCELLED` 拒绝整个工具调用。常规输入区只有在 host 的 resolved 帧移除待处理项后才会恢复。 -`dsh-tui` 将每个问题渲染为键盘叠层,展示选项描述,支持单选、多选和自由格式自定义答案,并在中止、provider dispose(资源释放)或终端关闭时拒绝待处理的问题。批量请求和并发请求都会排队,确保同一时刻只有一个叠层占用键盘焦点。 +`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`。 @@ -34,9 +34,9 @@ ACP 映射有意使用 elicitation 而非 `session/request_permission`。`reques ## 曾考虑的替代方案 -**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化选项元数据,UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user prompt 到达,而非作为需要答案的那次操作的结果。 +**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化选项元数据,UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user 提示词到达,而非作为需要答案的那次操作的结果。 -**核心拥有的 ask-user 包。** 最初实现将 seam 和面向模型的工具分别放在 `packages/core` 和 `packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互功能。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction` 和 `dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包的划分与产品边界一致:应用和 bridge 提供人类答案的 provider,stdio 应用选择性加载面向模型的工具。 +**核心拥有的 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` 是带可选自由格式答案的信息收集。将权限用于通用提问会混淆两个不同的产品概念,并使未来的权限门禁更难推理。 @@ -46,10 +46,10 @@ ACP 映射有意使用 elicitation 而非 `session/request_permission`。`reques ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应限制在 `dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。 -该功能赋予模型一个强大的暂停原语,因此 prompt 引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。 +该功能赋予模型一个强大的暂停原语,因此提示词引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。 -`dsh-user-interaction` 和 `dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或 provider。`dsh-tui-demo` 选择性加载 seam、TUI provider 和面向模型的工具。`dsh web` 在 host 运行时启动 seam/提供方,并通过选定的 Web question 插件暴露该工具。`acp-agent` 默认只保留 `userInteraction` seam/provider:ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。 +`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 请求后才有意加载面向模型的工具。 ## 测试 -单元覆盖率固定了以下场景:provider 注册/释放、重复 provider 拒绝、provider 就绪前中止、空问题拒绝、通过 `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 提交、按项跳过保留、输入区接管、结构化批量提交,以及常规输入区的恢复。 +单元覆盖率固定了以下场景:提供方注册/释放、重复提供方拒绝、提供方就绪前中止、空问题拒绝、通过 `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 提交、按项跳过保留、输入区接管、结构化批量提交,以及常规输入区的恢复。 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index b44e8db0db..e6fc4aba97 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -3,4 +3,4 @@ # 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: 565db37b1bf36c2aa33109870df4b36a82a57bdc +2026-06-29-todo-write-tool.zh.md: 3afc03a393284e2a9c2d6e234bc95f0faebbfb48 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 565db37b1b..3afc03a393 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -38,7 +38,7 @@ claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2) ### 校验:低成本的中间路线 -schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重复的 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给 prompt 约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。 +schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重复的 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给提示词约束;oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。 ## 为何没有 cordis-catalog 条目 / 没有 `@mode` @@ -51,7 +51,7 @@ schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重 - **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。 - **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write`;`todo/write` 事件落地,第二次调用替换它。 - **`session/load` 回放**——持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。 -- **带密钥 e2e + 快照**——真实 prompt 诱导一次 `todo_write`;快照预期输出获得 `plan` 通知和日志事件。 +- **带密钥 e2e + 快照**——真实提示词诱导一次 `todo_write`;快照预期输出获得 `plan` 通知和日志事件。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 36b1621ad7..cfcf477426 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -3,4 +3,4 @@ # 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: 3b1fe00ffcd26eaf42af0c089d4659bcef60763d +2026-06-30-hook-bridges.zh.md: 5f3adef958738d9d23c159c388be35b89f321828 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 3b1fe00ffc..5f3adef958 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -8,7 +8,7 @@ Status: implemented 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 Code(CC)和 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。各 package 的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 +贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 ## 决策 @@ -39,7 +39,7 @@ CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决 ### 添加上下文不是否决——先 delegate,再 prepend -仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall 监听器中不调用 `next()` 就返回 `allow`/`accept`,会短路其后的每个 `agent/prompt-submit` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游决策。两个 seam 都携带有序的 `additionalContexts` 数组,因此桥接会在保留所有下游来源、信封和元数据字段的同时,前置加入其独立来源的条目;下游 prompt block 仍会丢弃所有上下文,因为提示词从未到达模型,而 post-tool block 语义可以显式保留上下文。Code Mode 会通过外层 `run_code` 结果转送同一数组。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:上下文钩子允许后,较晚的监听器仍能阻止提示词,且保留的 prompt 和 post-tool 上下文仍彼此分离。 +仅附加 `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 默认为会话工作区 @@ -67,4 +67,4 @@ Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 ` ## 后果 -匹配语义、退出码处理和合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支以及通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护 package 的导出形态。原生插件绕过协议格式,直接返回类型化决策。 +匹配语义、退出码处理和合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支以及通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护包的导出形态。原生插件绕过协议格式,直接返回类型化决策。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 8c9946e1e0..de9949114b 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -3,4 +3,4 @@ # 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: 37bdd1ad0c4f7b8e980b9f81cff787b8896dfc4c +2026-06-30-hook-protocol-lib.zh.md: f4950eea2b02e86ed7109f8ebdaab29dd77428d8 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 37bdd1ad0c..f4950eea2b 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Code(CC)钩子,另一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code`、`~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约,以及相同的 command-hook 执行模型。Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了「有意偏离」之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑。 +钩子子系统提供两个桥接插件:一个运行用户既有的 Claude Code(CC)钩子,另一个运行 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`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言专属之间的分界是本设计的重心。 @@ -16,10 +16,10 @@ hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Cod **共享(本库):** - **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。 -- **Execution** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行 command hook:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 -- **Decode** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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(文本记录),因此无需抑制;见 [tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。 -- **Merge** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,block reason 以 `\n\n` 拼接,context/system-messages 按序累积。 -- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,declaration-merge 进 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 +- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 +- **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `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 payload(CC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换(CC)vs 无替换(Codex),以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision(`PreToolDecision`、`PromptDecision`、`ContinuationDecision`、`PostToolDecision`)。 @@ -29,4 +29,4 @@ hooks 子系统提供两个桥接插件:一个运行用户既有的 Claude Cod ## 后果 -每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与 merge 逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、merge 优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 +每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index f04945766d..639a586e7c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -3,4 +3,4 @@ # 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: fbaecf23d83357bd38984ef6948f5c2ef45e90b1 +2026-06-30-interception-seams.zh.md: 3886ed21504abfe14a2ccb344d7aa0d3f8d25064 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index fbaecf23d8..3886ed2150 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**「原生钩子」不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell-hook 协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。 +harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那样在生命周期节点扩展或管控 agent(智能体)。驱动本设计的关键视角转换是:**「原生钩子」不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*;CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell 钩子协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。 -该表面需要为以下场景提供各自独立的契约:逐 prompt 策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md)提供了三域规则与类型化 Decision 惯用法;本 Agent Note 将其应用于生命周期 seam。 +该表面需要为以下场景提供各自独立的契约:逐提示词策略(CC 的 `UserPromptSubmit`)、会话启动观测(CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md)提供了三域规则与类型化 Decision 惯用法;本 Agent Note 将其应用于生命周期 seam。 ## 决策 @@ -16,7 +16,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 **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` 可以重写 prompt `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会追加一条持久的 `prompt/blocked`,并拒绝这个零步骤轮次。 +- `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`,因此其类型不提供持久上下文元数据。 @@ -28,22 +28,22 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 - **`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`、结构化错误身份、上下文或呈现元数据。工具在此执行自身最后一道内容不变式,而无需将策略失败转换为更弱的 block 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 被 `prompt-submit` 阻止的零步骤轮次。 +**`TurnEndReason.rejected`**(`dsh-session`):取得所有权的提示词被 `prompt-submit` 阻止的零步骤轮次。 ### 三个承重的循环决策 -1. **在 prompt 策略之前开启轮次。** 被阻止的 prompt 成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。否决记录 `prompt/blocked`(含原始 prompt 和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。 +1. **在提示词策略之前开启轮次。** 被阻止的提示词成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。否决记录 `prompt/blocked`(含原始提示词和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。 -2. **Post-tool `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 显式提供的上下文。 +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,而非下一*轮次*的 prompt(与现有的 `hasSteering` 强制继续覆盖一致)。 +3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的提示词(与现有的 `hasSteering` 强制继续覆盖一致)。 -### Pre-tool 输入重写是一个独立的一致性决策 +### 工具执行前输入重写是一个独立的一致性决策 `PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前同时更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。 @@ -53,9 +53,9 @@ seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志); ## 曾考虑的替代方案 -- **将 pre-tool 输入重写作为本 seam 集的一部分发布**:推迟,视为越界信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`),[pre-tool 输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。 +- **将工具执行前输入重写作为本 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、post-tool 上下文缓冲和 continuation;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各 package README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 +规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 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` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index d96961bf0e..b1ad660010 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml @@ -3,4 +3,4 @@ # 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: d21a2a233d60bd788b79eb39f0f358b8e5b90cae +2026-06-30-session-store-fork-api.zh.md: d17daa464ad59c6b5ac4d5041b4af30c44321fa7 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index d21a2a233d..d17daa464a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -8,11 +8,11 @@ Status: implemented 事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。 -语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork,会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与 provider-transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork,要么拒绝请求。 +语义上的风险在于 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 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久化工作都委托给现有的 session store 和持久化后端。 +`dsh-session` 直接在 `ctx.sessions` 上拥有常规活跃会话 fork 的能力。不设独立的 `dsh-session-fork` 包(package),也不设 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久化工作都委托给现有的会话存储和持久化后端。 store 暴露一个操作: @@ -30,7 +30,7 @@ class SessionStore extends Service { ## 曾考虑的替代方案 -**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在 session store 原语之上执行一层策略而去发现并安装第二个服务。 +**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在会话存储原语之上执行一层策略而去发现并安装第二个服务。 **两个函数:`snapshot()` 加 `fork()`。** 这保留了一个可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还使接口看起来比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 使 API 保持直接,同时仍支持对先前时间点的 fork。 diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml index 3024b92250..b02086964d 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml @@ -3,4 +3,4 @@ # 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: f433c3cbd451e982cfe96457794895120f9bc200 +2026-06-30-subagent-observe-enrich.zh.md: 4ffa18ccc1cc45283038cb6fb861f5ed09ba090b diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md index f433c3cbd4..4ffa18ccc1 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md @@ -14,17 +14,17 @@ Status: implemented **在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结束路径上,它是只读的类型化 `SubagentResult.output`,观察者无需持有 run 即可看到子 agent 产出了什么。在基础设施拒绝(不存在 `SubagentResult`)的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听方是受信任的同进程协作者,遵守借用不可变载荷的契约。 -两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观察附加到就绪的 provider run 上,发出 `subagent/start`,然后返回该 run;进程内监听方因此可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent,而远程 provider 无需在本地注册表中有对应条目。provider 启动被拒绝时不发出任何事件。回调保持仅观察,且逐监听方隔离确保一个异常订阅者不会阻塞活跃 run 或饿死后续监听方。 +两个事件仍为普通 **`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`。 +**`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 监听方、在进程内 provider 中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam Agent Note](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering(中途引导)subagent 重设计(同一个重设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 Agent Note 交付钩子桥接层当前所需的仅观察丰富化;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在重设计发生时的落点。 +控制流式 `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` 锚点标记了控制流版本在重设计发生时的落点。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index a7fdfbc9d7..ce050c16c6 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -3,4 +3,4 @@ # 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: 2405e6e6746569e9327f9eb5ecbb8b37e5ef1bee +2026-07-05-dynamic-workflows.zh.md: 9ac94d491e14915841d55b6f6f7ad631a61a98f3 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 2405e6e674..9ac94d491e 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -14,7 +14,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) ### 脚本契约(兼容 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`。pipeline 各阶段接收 `(prev, item, index)`,阶段之间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过日志化延迟处理,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。 +一次工作流调用包含 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)保持诚实。 @@ -26,7 +26,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) **信任前提**:工作流脚本与模型的 bash 访问具有相同的信任级别。引擎容纳有缺陷的脚本,并保证结果已 settled、值为 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。 -**为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限制了文档化的脚本表面,而 message-port RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`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)所有。 @@ -38,7 +38,7 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) ### 消费方(dsh-tool-workflow) -一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` prompt 段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。 +一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` 提示词段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。 ### 基础:subagent seam 上的结构化输出 @@ -56,7 +56,7 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 - **后台收集**(启动工具 → run id → 完成通知 → 收集),与 bash/subagent 后台统一一起设计。 - **日志化 + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会以脚本契约收紧的形式重新引入 CC 的确定性禁令(脚本目前可以读取时钟)。 -- **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到运行目录**(tool-call 事件已经持久记录了脚本)。 +- **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到运行目录**(工具调用事件已经持久记录了脚本)。 - **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延迟的消息大声拒绝)。 - **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。 - **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。 @@ -67,7 +67,7 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 - **宿主侧的恶意值防护**(无 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/workflow 之间统一设计一次,而非逐工具设计。 +- **后台执行作为默认**(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 断言边界。 @@ -77,4 +77,4 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 ## 后果 -扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和 message-port RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项快速失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。 +扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项快速失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。 diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml index d232083e19..1c1ae2daf2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml @@ -3,4 +3,4 @@ # 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: b5600b54537319763e2bf7d54748f5c637461cbd +2026-07-06-explicit-tool-order.zh.md: 5cdecc0e59ff00b6dce7134819f8230072d084cb diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md index b5600b5453..5cdecc0e59 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md @@ -30,7 +30,7 @@ Status: implemented - **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 抖动),评审中不可见。 - **插件依赖图的线性化**:该关系是偏序的,独立的工具插件不可比较;抖动发生时偏序已完全满足。 - **每个插件在其工具贡献上标注 `weight`**:将顺序分散到各插件中,仍需一个无人拥有的全局编号约定(section 的 `order` 分段已经展示了这种协调成本需要手工承担)。 -- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个成员存储,被组装之外的多方消费;排序是 prompt 组合的关注点,而组装逻辑已经拥有 section 的组合策略。 +- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个成员存储,被组装之外的多方消费;排序是提示词组合的关注点,而组装逻辑已经拥有 section 的组合策略。 - **在 `LlmService` 上加配置 + `orderTools()` 方法,由 loop 在记录 header 前调用**:可行,但仅为在远处应用一个策略就增加了一个公开服务方法和一处 loop 改动;每个未来的请求组合者都必须记得调用。在列表诞生处进行规范化使得无序列表不可表示,且零新增接口。 - **在 `llm.stream()` 内部规范化**:在 header 事件已记录之后才运行(抖动仍然存在),且需要重建深度冻结的信封,静默地解除了重建不变式。 - **穷举列表(无 rest 条目)**:每个新加载的工具插件都会导致启动失败;强制的 rest 条目使未列出的工具保持确定性,且其位置是显式的。 @@ -43,8 +43,8 @@ Status: implemented - 快照套件中唯一固定请求头的 fixture(`text-turn`)携带新的权威工具顺序;按照固定请求头设计,其他 ACP 快照仍将大块 header 清洗为 `{{system}}`/`{{tools}}`。 - 步骤之间的纯工具重排与其他 header 变更一样记录:一份原因是 `'change'` 的完整 `request/header` 快照。稳定的权威顺序会防止注册时序在普通路径上制造这类变化。 - `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 的转发链传递,因此部署时将其放在 app 配置中 `persona` 旁边即可;`dsh-llm` 和 agent loop 无需改动。 -- `toolOrder` 中拼错或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带错误消息,`agent/error` 镜像该消息,不打开步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。 -- 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的已列名称相同。这防止哨兵值变成一个歧义的真实工具,并保持「从不丢弃工具」的排序契约。 +- `toolOrder` 中拼错或未加载的工具名称在提示词组装时使轮次失败,而非启动时:loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带错误消息,`agent/error` 镜像该消息,不打开步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。 +- 工具提供方返回保留的 rest 条目名称时,其提示词组装失败形态与未知的已列名称相同。这防止哨兵值变成一个歧义的真实工具,并保持「从不丢弃工具」的排序契约。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index bee8c40ef0..48dc139dc6 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -3,4 +3,4 @@ # 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: 9e0c1615d02fed89ef0cda2a111e36385ad00497 +2026-07-06-sandbox.zh.md: b25c84a3467db26776007bb4888391ba69ada3cd diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 9e0c1615d0..b25c84a346 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -107,9 +107,9 @@ interface SessionEventMap { 沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'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 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom`。`session/set_config_option` 通过 permission 服务验证并切换,然后返回完整的刷新状态(规范契约)。 +**编辑器界面**是协议原生的[会话配置选项](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 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom`。`session/set_config_option` 通过权限服务验证并切换,然后返回完整的刷新状态(规范契约)。 -**轮次封闭是提交边界。** 开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定,在下一次 prompt 提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`,setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。 +**轮次封闭是提交边界。** 开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定,在下一次提示词提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`,setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。 #### 进程内工具 @@ -117,10 +117,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 ### 测试 -- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、permission preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。 -- **Keyless 真实 runner:** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定 permission 切换并拒绝未知 preset。CI 拒绝静默全跳过。 +- **单元测试:** 固定平台选择和 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 自动跳过。 -- **快照:** 固定 permission config-option 协议格式(wire format)、preset 和旋钮事件、prompt delta 和通知、以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。 +- **快照:** 固定权限 config-option 协议格式(wire format)、preset 和旋钮事件、提示词 delta 和通知、以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换。 ## 延迟阶段 @@ -142,16 +142,16 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **提供方上的配置固定模式**:否决。每进程一个模式;无法服务具有不同策略的并发消费方,也无法表达一次性放宽重试。 - **一个接口同时覆盖容器/VM**:否决。`confine(argv)` 预设共享文件系统;环境隔离是作为一致组部署的能力兄弟后端。 - **通用 ToolRuntime 包装任何工具**:否决。对进程内工具(闭包了 `ctx`)机械上不成立;声明式效果重写对 fs/web/todo 而言不合理。 -- **在执行器内部(`dsh-bash-sandbox`)请求批准**:否决。没有可路由的 `agent`,没有可附加 prompt 的 `callId`;添加它们会让传输 seam 了解会话和 UI——工具层持有两者并拥有面向模型的词汇。 +- **在执行器内部(`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 和 sandbox 独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 +- **通用 `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 已记录模型看到的确切 prompt;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。 +- **用专门的簿记事件追踪「上次告知」**:否决。`request/header` fold 已记录模型看到的确切提示词;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **ACP session modes 而非 config options**:否决。preset 已经是一个部署定义的 config-option 选择器,且 modes 计划在 ACP v2 中移除。 ## 后果 @@ -169,18 +169,18 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 代价与已接受的限制: -- **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加 prompt 约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 +- **单一包装的幻觉被有意放弃。**`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`:描述引导且枚举强制阶梯,但人的 prompt 是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 +- **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。 - **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。 - **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。 -- **空闲切换存在于 bridge 内存中,直到下一次 prompt 提交锚定它。** 该窗口内的崩溃将其回退(在 `session/load` 时报告),且永不再提交 prompt 的会话永不持久化它——已接受,loop 拥有的空闲提交轮次留作未来工作(如果持久性成为需求)。 -- **批准叙述器的重启基线解析 prompt 文本。** 封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑;header 早于该段落的会话静默采用当前策略而不发通知。 -- **批准段落仍是动态 prompt 表面**(`'never'` 切换会破坏该会话的提供方 prompt 前缀缓存)。已接受:策略切换罕见,且模型基于过时的 `'never'` 行动更糟。沙箱旋钮不再触及 prompt。 +- **空闲切换存在于 bridge 内存中,直到下一次提示词提交锚定它。** 该窗口内的崩溃将其回退(在 `session/load` 时报告),且永不再提交提示词的会话永不持久化它——已接受,loop 拥有的空闲提交轮次留作未来工作(如果持久性成为需求)。 +- **批准叙述器的重启基线解析提示词文本。** 封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑;header 早于该段落的会话静默采用当前策略而不发通知。 +- **批准段落仍是动态提示词表面**(`'never'` 切换会破坏该会话的提供方提示词前缀缓存)。已接受:策略切换罕见,且模型基于过时的 `'never'` 行动更糟。沙箱旋钮不再触及提示词。 - **模型可能持有关于沙箱模式的过时信念**(没有任何东西宣布切换)。有意接受:下一次尝试的标记或成功会纠正它,而宣布的观察到的失败模式——预防性拒绝——比一次浪费的重试更糟。 ## FAQ diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 2db4756817..7ffb7e55e5 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -3,4 +3,4 @@ # 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: 470d43ec8b4b1553304949278c26a6d1c9b9cc58 +2026-07-07-mcp-client-plugin.zh.md: ca87abc0ba3c61ae62e321a11d2f026ba5a33d5c diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 470d43ec8b..ca87abc0ba 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -167,7 +167,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 延后。ACP 桥接已将 harness 暴露为 agent 服务器。再加一层 MCP server 会以不同协议重复这一功能,而用户的首要需求是消费外部工具,而非暴露自身工具。 -### 能力 seam 三包拆分(interface / impl / consumer) +### 能力 seam 三包拆分(接口 / 实现 / 消费方) 否决。可预见范围内不会有替代的 MCP 客户端实现——MCP 只有一个协议、一个 SDK。约定是「不要预防性拆分」,直到出现第二种实现。 diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml index 429bb90068..63ac02b35e 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml @@ -3,4 +3,4 @@ # 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: 33afa701baf748b177a09995ab4ea4939b50f101 +2026-07-07-session-prefix.zh.md: 710cfbd2656d132640d39b1d62374ef2d16be612 diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md index 33afa701ba..710cfbd265 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前,harness 为这类内容提供了两个归属位置,但两者都不合适。系统提示词是一个渲染后的单一字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(`agent.inject()`、会话启动时的 `context/message`)使开场内容变为永久:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会将其以陈旧状态固化,resume 也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。 +插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前,harness 为这类内容提供了两个归属位置,但两者都不合适。系统提示词是一个渲染后的单一字符串:消息形态的内容(user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(`agent.inject()`、会话启动时的 `context/message`)使开场内容变为永久:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会将其以陈旧状态固化,恢复也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。 显而易见的第三种选项——让插件在请求发出途中编辑 `messages`——被[可重建请求 Agent Note](../architecture/2026-07-05-reconstructable-requests.md)禁止:每个由循环构建的请求都是会话日志的纯函数,因此无论哪个通道承载开场内容,都必须精确记录它所发送的内容。缺失的是一个带有持久记录的仅请求消息通道。 @@ -17,8 +17,8 @@ Status: implemented 三个属性承载了这一设计: - **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 Agent Note 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。配套的 [`dsh-agent-loop/invariant`](../../../../packages/core/agent-loop/src/invariant.ts)对每个循环构建的请求重新计算 `messagePrefix + boundary derivation`;启用该贡献时,未记录的前缀无法到达协议格式。 -- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的 prompt 缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `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 压力会将精确前缀与实际 prompt、工具和已路由模型一起读取;通用的步骤前检查点 seam 不携带压缩专属参数。被 cancel/dispose 中断的组合会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。 +- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的提示词缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `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 状态从不进入表面。 @@ -30,9 +30,9 @@ Status: implemented - **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:一个每请求触发的 waterfall,贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入漂移,必须记录为完整的变更 header;`after` 槽位位于不断增长的历史之后,其 token 在每个请求中重复支付,且其后的所有内容不可缓存。对照各替代方案衡量,当前所有更新模式都能通过持久追加更廉价地满足(支付一次,此后缓存读取),而唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。 - **系统提示词分段**(`system-prompt/assemble`):对此类内容否决。assembly 渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带完整的变更 header),而开场内容需要按实例冻结的语义。 -- **持久化历史开场**(会话启动时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、跨 resume 陈旧。 +- **持久化历史开场**(会话启动时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、在恢复后仍保持陈旧状态。 - **按轮次组合而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制产生变更 header;且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界,`'resume'` 快照已在那里可追溯地记录漂移。 -- **通过 `agent/pre-step` 携带 prompt/prefix,用于临时压力估算**:否决,因为它把通用生命周期 seam 耦合到一个消费方,而且仍会遗漏更晚的请求路由和工具;步骤后的回放会从持久的已路由 header 读取请求信封的每个字段。 +- **通过 `agent/pre-step` 携带提示词/前缀,用于临时压力估算**:否决,因为它把通用生命周期 seam 耦合到一个消费方,而且仍会遗漏更晚的请求路由和工具;步骤后的回放会从持久的已路由 header 读取请求信封的每个字段。 - **专用会话事件承载前缀**:否决。header 事件按设计就是请求的非历史记录;第二个事件会为同一事实提供第二个归属,并多出一个需要保持完整的编解码器。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml index 48e142e853..95661f953c 100644 --- a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml @@ -3,4 +3,4 @@ # 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: 597a51c3a9b160f0447d22e8197917f59cd9ce11 +2026-07-08-repeat-tool-guard.zh.md: 01037f29810c781c33beee414e50412a0c9b0f89 diff --git a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md index 597a51c3a9..01037f2981 100644 --- a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md @@ -14,7 +14,7 @@ harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 s 该守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻止或改写调用;模型自行决定是换种方式重试还是结束。 -插件为 `@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` 中——工具注册表是 context 级别的单例,其 waterfall(瀑布式事件)交错所有 agent(智能体)的调用(subagent 运行在同一个 context 上),因此按 agent 分键是正确性要求,而非锦上添花;弱对象键还使得纯清理用途的 disposal 监听器不再必要。 +插件为 `@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 map(pi 扩展需要它,仅因为其 `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 的链。用户介入改变了上下文;跨越介入的重复不是循环。 @@ -30,7 +30,7 @@ harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 s ### 提醒投递 -提醒作为独立条目搭载在 `additionalContexts` 上(source 为 `{kind: 'plugin', plugin: 'repeat-tool-guard'}`——依照 `HookContext`,该标签承载语义),绝不替换 `content`:`tool/result` 事件仍是工具自身的审计输出,循环则在步骤结果之后把缓冲的上下文追加为 `context/message`,session 将其渲染为带标签的合成 user 信封,并由派生历史回放。阈值逐级升级:第一个配置阈值获得简短的「你正在重复自己,请分析先前结果」提示;后续各阈值获得详细形式,包含工具、重复计数和规范参数(在头部截断到 `argumentsPreviewChars`,默认 500——循环中的 `write` 级 payload 不得无界地进入下一次请求;链键始终比较完整规范字符串),并说明这些调用没有取得进展。pi 原版把温和文本硬编码为字面计数 3;本守卫以 `thresholds[0]` 为键,修复了移植中的这一 bug。下游钩子桥贡献仍是独立数组条目,因此两个插件都保留各自的 source、信封与元数据。 +提醒作为独立条目搭载在 `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、信封与元数据。 ### 配置 @@ -48,7 +48,7 @@ harness 已经具备 pi 扩展所使用的全部 seam,而且更好:[拦截 s ## 测试 -- **单元测试:** 使用脚本化适配器的真实循环,覆盖计数与重置规则、未追踪透明性、dispose(资源释放)清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游 block 或 replacement 决策,达到逐文件 100% 覆盖率。 +- **单元测试:** 使用脚本化适配器的真实循环,覆盖计数与重置规则、未追踪透明性、dispose(资源释放)清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游阻止或 replacement 决策,达到逐文件 100% 覆盖率。 - **快照测试:** keyless 的 `repeat-tool-guard` 场景发起五次相同的 `todo_write` 调用,在 ACP 输出和会话日志中固定第三次调用的温和提醒与第五次调用的详细提醒。该插件在实时示例中加载,但在其他场景中保持静默。 - **E2e 测试:** 无。该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 8803b75623..cb3d29f1ab 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -3,4 +3,4 @@ # 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: bdc8bfb7ed3b099fde2eb5d192800b2d6286201f +2026-07-08-self-referential-cordis-toolset.zh.md: 75d024c49878e26ecbda416d24c02941a1c754db diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index bdc8bfb7ed..75d024c498 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -46,7 +46,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 生成的 API 目录 -`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、原始服务方法与事件 JSDoc、事件模式、引用的类型声明以及继承的 context 接口面。有歧义的类型名被省略,过大的声明被标记为截断。 +`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、原始服务方法与事件 JSDoc、事件模式、引用的类型声明以及继承的上下文接口面。有歧义的类型名被省略,过大的声明被标记为截断。 新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此 JSDoc 或公开签名变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:宽泛报告把有目录条目的活跃服务渲染为摘要 + 签名,把没有目录条目的活跃服务(挂载提供的)渲染为名称 + 所属 fiber,简要列出有目录条目但无活跃提供方的服务,再附上引用的类型形状。精确名称报告渲染一个活跃服务或事件,并把原始 JSDoc 紧靠在每个签名之前;让该细节按需出现,避免探索性列表承担其 token 成本。 @@ -75,7 +75,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 **新增 `cordis/mount` 会话事件。** 一个持久的溯源事件记录每次挂载(源码、名称)有明确先例(`hook/invoked`、`compact/start`)。v1 中予以否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为完整的变更 request header 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源从工具调用中分离出来,日后仍可添加。 -**加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始 context,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的 context 逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。 +**加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始上下文,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的上下文逃逸,但门面暴露的能力(`ctx.bash`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 276ea90554..3dc9ca062a 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -3,4 +3,4 @@ # 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: ec18becb9c3f446138e7b3573582f3ff2fbb891a +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: be1c2c6a389bb7d800bc7a6553da93a5cf912c7f diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index ec18becb9c..be1c2c6a38 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -18,7 +18,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma | 控制 | 问题 | 结果 | |---|---|---| -| `persona` | 什么角色指令替换该子 agent 的部署人设? | 一个子 agent 局部的 prompt 段落遮蔽 `deployment:persona` | +| `persona` | 什么角色指令替换该子 agent 的部署人设? | 一个子 agent 局部的提示词段落遮蔽 `deployment:persona` | | `toolFilter` | 部署全局工具中哪些进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 | | `maxDepth` | 这棵委派树最深可以长到多少层? | 子 agent 深度超过绝对上限时,启动请求被拒绝 | @@ -26,11 +26,11 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ### 人设是有作用域的遮蔽 -人设控制改变一个子 agent 的行为,而不改变部署级的 prompt 组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。 +人设控制改变一个子 agent 的行为,而不改变部署级的提示词组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。 其值与部署人设具有相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局人设。父级和兄弟级的人设永远不会进入子 agent 的扁平作用域。 -这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次 prompt 看到的命名贡献与后续 prompt 和 prompt 检查工具看到的一致。 +这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次提示词看到的命名贡献与后续提示词和提示词检查工具看到的一致。 ### 工具过滤是一条作用于全局视图的活规则 @@ -51,7 +51,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 深度限制独立于工具可见性来约束递归委派。顶层 agent 深度为零;进程内子 agent 的深度为其父级已验证深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。 -有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在 session header 中,resume 会恢复该 header,因此重启无法降低递归计数。 +有效父级深度取持久 `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` 时,数值工具上限会在提供方挂载阶段失败。 @@ -67,7 +67,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装人设、工具限制和结构化输出贡献。只有设置成功后,创建才发布会话和 agent 并允许驱动器启动。 -设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次 prompt 使用了部署人设或未过滤工具集、后续 prompt 才使用所请求配置」的子 agent。 +设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次提示词使用了部署人设或未过滤工具集、后续提示词才使用所请求配置」的子 agent。 ## 可见性不是授权 @@ -85,7 +85,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma **在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册统一不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为活跃的注册表谓词,并直接记录 allow 与 deny 的行为。 -**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个 prompt 声称不存在的工具。改为由一个解析器同时管控呈现和执行。 +**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个提示词声称不存在的工具。改为由一个解析器同时管控呈现和执行。 **把深度上限编码为自动工具过滤器。** 创建时过滤器会快照一个可能依赖运行时状态的决策,只影响一个已配置工具名,且不保护直接服务调用方或替代委派工具。提供方改为在每次启动时强制绝对上限。 diff --git a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml index 9a7213863f..19b6055f0a 100644 --- a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-doc-sync-enforcement.md: 375059312c312dff7b5ddcb95ea5b82ac8cd4d06 -2026-06-11-doc-sync-enforcement.zh.md: 7d0e812c7a4b0ec64113cd272b2fcbdfb6055f18 +2026-06-11-doc-sync-enforcement.zh.md: 5c17263bdc2b4908a82237d1fc3b08f1f22a62d9 diff --git a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md index 7d0e812c7a..5c17263bdc 100644 --- a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md @@ -29,4 +29,4 @@ AGENTS.md 承诺文档与代码严格同步,但这一承诺此前仅靠人眼 - 可检查类别中的文档漂移会直接使 `doc-sync` 和 CI 失败,而不是等评审人发现。这是「机械门禁优于行文规范」原则的具体应用。 - 让文档代码片段可编译需要少量 stub import/`declare`;`ignore-check` 比例必须保持低位,否则门禁形同虚设(比例守卫强制执行此约束)。 - 分类体系检查仅限名称——Mode 或 Purpose 列的错误仍需人工评审。 -- 如果 package 未来对外发布,API 报告方案仍可重新考虑。 +- 如果包(package)未来对外发布,API 报告方案仍可重新考虑。 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml index 0e84de0e10..6d017a8961 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-quality-gates.md: e1af110387936d644208dc1829fde4a4fdf8a3f9 -2026-06-11-quality-gates.zh.md: eaae458e3eac6a45e0c16ec0b8fb950aa1b70619 +2026-06-11-quality-gates.zh.md: a4e57b7a08ecf20babb33b55d8c94414df1b10b1 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md index eaae458e3e..a4e57b7a08 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md @@ -14,11 +14,11 @@ Status: implemented 每条可机械检查的 AGENTS.md 承诺都有一个以非零状态退出的命令。CI 执行完整集合,而 Git 钩子将延迟预算留给可低成本发现的本地缺陷: -- 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而 package/vendor 代码保持在各自 project-reference 边界之后。 +- 最严格的 TypeScript 配置(`noUncheckedIndexedAccess`、`exactOptionalPropertyTypes` 等);示例、测试和脚本通过根目录的 no-emit `tsconfig.json` 在 CI 中进行类型检查,而包(package)/vendor 代码保持在各自 project-reference 边界之后。 - ESLint strict-type-checked + @stylistic(作为强制执行的统一代码风格),包括文件内重复逻辑检查;vendor 代码排除在外。 -- jscpd 检测 package 生产 TypeScript 与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 +- jscpd 检测包的生产 TypeScript 代码与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 - `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 -- knip(死代码/依赖)、publint(包(package)正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 +- knip(死代码/依赖)、publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 - lefthook pre-commit 修复已暂存文件的 lint 问题、拒绝已暂存的空白问题并检查 vendor manifest;pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml index f29e69fbcf..4ac1a926c4 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-tsdown-over-dumble.md: e8cdaeb1e3331ffb04de024acff5b0e6ca3e6366 -2026-06-11-tsdown-over-dumble.zh.md: 97cbfe8cda6c2f2a5738c8221843735454b34183 +2026-06-11-tsdown-over-dumble.zh.md: bb5feef585c1748f41d1b204313f1a4d8b357a17 diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md index 97cbfe8cda..bb5feef585 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md @@ -16,7 +16,7 @@ Status: implemented - 根目录 `tsdown.config.ts`,配置 `workspace: ['vendor/*', 'packages/*/*']`(显式 glob 将打包范围限定在 vendor 的 Cordis 与 TypeScript 包目录树内;`workspace: true` 还会发现示例 manifest 和不需要打包的 workspace 成员)。 - 共享形状:入口为 `lib/types/index.js`,`outDir: 'lib'`,ESM,`platform: node`,`target: es2024`,`fixedExtension: false`(为 `"type": "module"` 包保留 `.js`),`dts: false`(声明归 tsc -b 所有),`clean: false`(lib/ 还保存 TSC 的 `lib/types` 中间树)。入口最初是 `src/index.ts`;[TSC 优先构建 Agent Note(agent 决策记录)](2026-06-17-ts-build-config.md)随后将 tsdown 改为打包 TSC 输出的 JS,使 TypeScript 转换行为统一由一个编译器提供。 -- vendor/ 中有两个按包覆盖的配置(属于我们自己的修改,与重新生成的 tsconfig 类似;记录在 vendor/README.md 中):schemastery(通过 `outExtensions` 输出双格式 `.mjs`/`.cjs`)、logger-console(两次单入口 pass,使共享基类被内联到每个入口而非生成哈希命名的 chunk,与上游发布形态一致)。 +- vendor/ 中有两个按包覆盖的配置(属于我们自己的修改,与重新生成的 tsconfig 类似;记录在 vendor/README.md 中):schemastery(通过 `outExtensions` 输出双格式 `.mjs`/`.cjs`)、logger-console(两次单入口 pass,使共享基类被内联到每个入口而非生成哈希命名的分片,与上游发布形态一致)。 - `scripts/build.ts` 删除;`pnpm run build` = `tsc -b && tsdown`(根 solution 拥有 emit 图)。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index cb31fe2ed1..c824a95b5e 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-16-pnpm-over-yarn.md: 9dee405f509897e2a173399e466d574c518fa9ab -2026-06-16-pnpm-over-yarn.zh.md: ef34e6ba5d6e22037668c9aa3507dfd0f5438ad4 +2026-06-16-pnpm-over-yarn.zh.md: 1f289444bc4cf8dc6b0691be9d4e318b18f5342d diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index ef34e6ba5d..1f289444bc 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -15,7 +15,7 @@ Status: implemented 采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同): - **Workspaces** 从 `package.json` 的 `workspaces` 数组 + `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml`(`vendor/*`、`packages/*`——同样的 glob;`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。 -- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会让幻影依赖(引用未声明的传递依赖)大声失败,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.md))是一项*优势*。门禁套件(typecheck、lint、test、build、knip)是证明不存在此类幻影导入的安全网。 +- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会让幻影依赖(引用未声明的传递依赖)大声失败,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.md))是一项*优势*。门禁套件(类型检查、lint、test、build、knip)是证明不存在此类幻影导入的安全网。 - **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非将其加入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild`、`lefthook`、`@google/genai`、`protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在也应用于安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。 - **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查 privacy。 - 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词变为 `pnpm …` / `pnpm run …`。`yarn.lock` → `pnpm-lock.yaml`(lockfile v9)。`.gitignore` 将 `.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保持其上游 `yarn` 示例不变。 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 1c1b0f7b26..80f7838d45 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-17-ts-build-config.md: 1275a635242ea887c941db9dc0554fd33acd4102 -2026-06-17-ts-build-config.zh.md: 3b20d30011f84ae1a40182c23fa0743c608bb2ab +2026-06-17-ts-build-config.zh.md: 7691118dd152874e2849f1ace686069c9ea3f61f diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 3b20d30011..7691118dd1 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -11,7 +11,7 @@ Status: implemented 此前的 TypeScript 构建与类型检查配置存在以下问题: - `build` 使用 `tsc` 将 `packages/<group>/<pkg>` 和 `vendor/*` 下的 `.ts` 转换为 `.d.ts` 文件,然后使用 `tsdown` 将 `.ts` 转换为打包后的 `.js` 文件。这导致两个工具各自执行 TypeScript 转换。 -- `typecheck` 倾向于通过一个根目录的 typecheck 配置来校验 package、vendor 源码、示例、测试和脚本。 +- `typecheck` 倾向于通过一个根目录的类型检查配置来校验包(package)、vendor 源码、示例、测试和脚本。 目标是让构建与类型检查使用一致的 tsconfig 边界和 TypeScript 解析/转换行为。构建应通过单一编译器和配置生成 `.js`、`.d.ts`、`.js.map` 和 `.d.ts.map`,使发布产物与类型校验保持一致。 @@ -32,16 +32,16 @@ Status: implemented `pnpm run build` 是两阶段构建: -- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各 package 的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts` / `.d.ts.map`,忽略 `.js` / `.js.map`。 - - 该图是从根 solution `tsconfig.json` 经两个聚合可达的 project-reference 图([拓扑](2026-07-22-tsconfig-solution-root-two-aggregates.md)),用于校验并输出 package/vendor 的构建结果。 +- 阶段 1:在根 solution 上执行 `tsc -b`,将逐模块的 `.js`、声明文件 `.d.ts`、JS sourcemap `.js.map` 和声明 sourcemap `.d.ts.map` 输出到各包的 `lib/types`。这是权威的 TypeScript 编译结果。发布时保留 `.d.ts` / `.d.ts.map`,忽略 `.js` / `.js.map`。 + - 该图是从根 solution `tsconfig.json` 经两个聚合可达的 project-reference 图([拓扑](2026-07-22-tsconfig-solution-root-two-aggregates.md)),用于校验并输出包/vendor 的构建结果。 - 阶段 2:打包器读取 `lib/types` 下输出的 JS,将打包后的运行时入口写为 `lib/index.js` 或 `lib/index.mjs`(沿用当前行为)。此阶段仅做打包,禁止读取 TypeScript 源码或输出声明文件。 `tsdown` 不再负责 TypeScript 编译或声明文件输出。 `pnpm run typecheck` 运行同一张 `tsc -b` 图。 -- 两个聚合(`tsconfig.host.json`、`tsconfig.client.json`)以 `noEmit` 方式检查示例、测试和脚本,并通过 references 校验 package/vendor 源码。 -- 被引用的 package/vendor 项目保持与 build 相同的输出行为,因此 typecheck 会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 -- 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。package/vendor 的 emit 项目保持重写开启。 +- 两个聚合(`tsconfig.host.json`、`tsconfig.client.json`)以 `noEmit` 方式检查示例、测试和脚本,并通过 references 校验包/vendor 源码。 +- 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 +- 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 命令编排结构如下: @@ -62,7 +62,7 @@ tsc -b ## 曾考虑的替代方案 - **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(装饰器转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。 -- **用一个根目录严格程序覆盖 package、vendor、示例、测试和脚本**:vendor 源码在根目录严格标志下会触发不属于本项目所有权范围的类型错误;带有逐项目严格度的 project references 才是可行的边界。 +- **用一个根目录严格程序覆盖包、vendor、示例、测试和脚本**:vendor 源码在根目录严格标志下会触发不属于本项目所有权范围的类型错误;带有逐项目严格度的 project references 才是可行的边界。 ## 后果 @@ -75,6 +75,6 @@ tsc -b - `lib/types/*.js` 仅作为打包器输入,禁止用作运行时入口或公开导入目标。 - `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。 - `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。 -- `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,package 和 vendor 模块保持与 `build` 相同的输出行为。package 和 vendor 源码始终处于 project-reference 边界之后。 +- `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 Cordis 的 vendor 副本现在与上游多了一处类型结构差异。在上游同步时,该差异必须被重新应用或明确废弃。 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 87c9dfe066..877bf2cdf2 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-core-data-structures-catalog.md: d7e9d3d9b14fe723e3396c8167fe714b7613e2b5 -2026-06-20-core-data-structures-catalog.zh.md: a998c556020f99c34f2725a048401579e983b353 +2026-06-20-core-data-structures-catalog.zh.md: 48eb661f6bf777a71d4da3be43a30c840b7aa0b2 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index a998c55602..48eb661f6b 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、session/轮次/步骤生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解“什么是 `Message`、`SessionEvent`、`StreamChunk`”,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 +试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、会话/轮次/步骤生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解“什么是 `Message`、`SessionEvent`、`StreamChunk`”,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 -因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十个跨包类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note(agent 决策记录)记下了这两项决策。与它配套的[生成式 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 +因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十种跨包(package)边界的类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note(agent 决策记录)记下了这两项决策。与它配套的[生成式 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 ## 决策 @@ -25,7 +25,7 @@ Status: implemented - `ToolSchema` 是核心(它是流经每个步骤的模型请求 `GenerateOptions` 的一个字段),即使它在概念上属于工具流水线——当*流经主干*与*概念归属*冲突时,前者胜出。 - 工具展示词汇(`ToolCallView`/`ToolResultView` 等)、`SessionPersistence` 持久性 seam 以及 bash 词汇是子页面。 -`core.md` 是一份**自包含的主干文档**:它给出每个主干结构的确切类型定义,辅以最少的行文,并链接到子页面获取各 seam 的细节。子页面包括 `llm-streaming.md`、`session.md`、`persistence.md`(沿内存模型与持久性 seam 的分界线从 session 拆出)、`tools.md` 和 `bash.md`。 +`core.md` 是一份**自包含的主干文档**:它给出每个主干结构的确切类型定义,辅以最少的行文,并链接到子页面获取各 seam 的细节。子页面包括 `llm-streaming.md`、`session.md`、`persistence.md`(沿内存模型与持久性 seam 的分界线从会话页面拆出)、`tools.md` 和 `bash.md`。 ### `ts type-equiv` 机制——既逐字又防漂移 @@ -48,7 +48,7 @@ Status: implemented ## 验证教训 -主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及 session/persistence 拆分的逐一测试。 +主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及会话/持久化拆分的逐一测试。 `verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是清单点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为孤儿。本 Agent Note 将这条失败关闭扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成式 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml index 65d734319e..996cc824f6 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-tool-schema-catalog.md: c8cc69df428f6eee0f66ed976865afe2a0702448 -2026-07-02-tool-schema-catalog.zh.md: 9fb92e7413fc60df894a53bc077336ba29825bc5 +2026-07-02-tool-schema-catalog.zh.md: 1494d982df284b192767af2b22ef53fa87a79d7e diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md index 9fb92e7413..1494d982df 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -目录通过**启动每个工具插件并读取其已注册 schema** 来生成,而不是解析源码。`scripts/gen-tool-catalog.ts` 在全新的 Cordis `Context` 上挂载每个已发布工具包(带有 `SystemPrompt`、`ToolRegistry` 以及插件 `apply` 所读取的注入 seam),调用 `ctx.tools.schemas()`——也就是发送给模型的确切 `ToolSchema[]`——随后释放上下文,并为每个包渲染一个 `## <package>` 章节,每个工具附带一个 ` ```json ` `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形状一致:默认 `--write` 重新生成;提交副本陈旧时 `--check` 失败;输出具有确定性(按清单排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一项新鲜度检查。 +目录通过**启动每个工具插件并读取其已注册 schema** 来生成,而不是解析源码。`scripts/gen-tool-catalog.ts` 在全新的 Cordis `Context` 上挂载每个已发布工具包(package);该上下文还提供 `SystemPrompt`、`ToolRegistry` 以及插件 `apply` 所读取的注入 seam。生成器调用 `ctx.tools.schemas()`——也就是发送给模型的确切 `ToolSchema[]`——随后释放上下文,并为每个包渲染一个 `## <package>` 章节,每个工具附带一个 ` ```json ` `parameters` 块。它与 `gen-cordis-catalog` / `gen-module-graph` 的 CLI 形状一致:默认 `--write` 重新生成;提交副本陈旧时 `--check` 失败;输出具有确定性(按清单排序,工具按名称排序)。`verify-tool-catalog`(即 `--check`)在 `doc-sync` 内运行,因此相关文档变更和 CI 会执行同一项新鲜度检查。 ### 为何启动而非解析(核心要点) diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml index 0173ef2543..dbaa77f5ef 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-03-documentation-graph-atlas.md: 9a30b13f9db6ceb2715517230e349cbe083850ec -2026-07-03-documentation-graph-atlas.zh.md: ffa29ec4b2237fe51282bac5dae4bbc5294b2824 +2026-07-03-documentation-graph-atlas.zh.md: 0734a2c659459f40ee81b9960f259681cb35891c diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md index ffa29ec4b2..0734a2c659 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -仓库已经有若干高可信文档表面,各自覆盖不同维度:[module-graph.md](../../../../docs/module-graph.md) 根据包的 `peerDependencies` 生成;生成式 [Cordis 事件](../../../../docs/cordis-catalog/events.md)和[服务](../../../../docs/cordis-catalog/services.md)目录根据 Cordis `Events` 和 `Context` 声明生成;[tool-catalog.md](../../../../docs/tool-catalog.md) 通过启动已发布工具插件生成;[core-data-structures/](../../../../docs/core-data-structures/core.md) 则使用 `ts type-equiv` 块使粘贴的类型定义与源码保持同步。 +仓库已经有若干高可信文档表面,各自覆盖不同维度:[module-graph.md](../../../../docs/module-graph.md) 根据包(package)的 `peerDependencies` 生成;生成式 [Cordis 事件](../../../../docs/cordis-catalog/events.md)和[服务](../../../../docs/cordis-catalog/services.md)目录根据 Cordis `Events` 和 `Context` 声明生成;[tool-catalog.md](../../../../docs/tool-catalog.md) 通过启动已发布工具插件生成;[core-data-structures/](../../../../docs/core-data-structures/core.md) 则使用 `ts type-equiv` 块使粘贴的类型定义与源码保持同步。 这些参考文档是准确的,但大多是目录式的。维护者仍需自行综合关系:哪些包构成一个能力 seam、哪个应用组装了具体的主干、哪些事件是持久的而哪些是实时的、钩子或策略插件在哪里可以拦截工作、以及哪个面向模型的工具依赖哪个服务。SDK 用户从另一个角度面临同样的问题:「我想要某种行为,应该安装或加载哪个包?应该扩展哪个事件/服务/工具?」 @@ -32,7 +32,7 @@ Status: implemented | 关系图 | 维护模式 | 真源 | |---|---|---| -| [模块依赖图](../../../../docs/module-graph.md) | 生成式 | `packages/*/*/package.json` 的 peer dependency 与包分组路径 | +| [模块依赖图](../../../../docs/module-graph.md) | 生成式 | `packages/*/*/package.json` 的对等依赖(peer dependency)与包分组路径 | | [工具 schema 目录与包映射](../../../../docs/tool-catalog.md) | 生成式 | 启动后采集的工具 schema,以及工具包服务/效应元数据 | | [能力 seam 与核心服务](../../../../docs/capability-seams.md) | 混合生成式 | Cordis 服务声明,以及 `gen-doc-graphs.ts` 中的角色清单 | | [tui-agent 应用组合](../../../../examples/tui-agent/composition.md) | 混合生成式 | `examples/tui-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | @@ -40,7 +40,7 @@ Status: implemented | [cordis-agent 应用组合](../../../../examples/cordis-agent/composition.md) | 混合生成式 | `examples/cordis-agent/cordis.yml` 插件列表,以及人工维护的应用/bundle 展开 | | [acp-agent 应用组合](../../../../examples/acp-agent/composition.md) | 混合生成式 | `examples/acp-agent/cordis.yml` 插件列表加人工策划的应用/bundle 展开 | | [事件生产者/消费方矩阵](../../../../docs/event-producer-consumer.md) | 混合生成式 | Cordis 事件声明、经 AST 扫描的 `ctx.on/emit/parallel/serial/waterfall` 位置,以及显式动态分派覆盖 | -| [agent 轮次与步骤生命周期](../../../../docs/agent-lifecycle.md) | 人工维护 | architecture.md 循环生命周期、Cordis 目录链接,以及 session 事件语义 | +| [agent 轮次与步骤生命周期](../../../../docs/agent-lifecycle.md) | 人工维护 | architecture.md 循环生命周期、Cordis 目录链接,以及会话事件语义 | | [工具执行管线](../../../../docs/tool-execution-pipeline.md) | 人工维护 | 工具管线语义与 `tools/execute` waterfall(瀑布式事件)| | [ACP(Agent Client Protocol)快照回放](../../../../packages/ui/acp/snapshot-replay.md) | 人工策划 | 快照 harness 行为 | diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 529c94c930..368f281520 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-doc-tiers-and-budgets.md: 9993eda6b0c7f1f8e5908dbd85fcfb4e5c6b3d1d -2026-07-04-doc-tiers-and-budgets.zh.md: d708919217a086928951c4acfed57985194301c4 +2026-07-04-doc-tiers-and-budgets.zh.md: f0f8910989ad23b98d314fd592d9691cf15c726f diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index d708919217..f0f8910989 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事件、重复的包映射,以及陈旧的 Agent Note(agent 决策记录)摘要。仅靠评审无法阻止这种增长,因此仓库需要在文档分类之外再配一套机械预算。 +尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事件、重复的包(package)映射,以及陈旧的 Agent Note(agent 决策记录)摘要。仅靠评审无法阻止这种增长,因此仓库需要在文档分类之外再配一套机械预算。 ## 决策 diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml index 7582c5e480..2356421d05 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-persistence-log-catalog.md: 1529f41b485c1bc8ca029c0a9264574fa7a886a0 -2026-07-04-persistence-log-catalog.zh.md: 24ac6caf8331c579828e707e289f28827caa072f +2026-07-04-persistence-log-catalog.zh.md: ff88a6c278033f2861365690faa7c9653e06f211 diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md index 24ac6caf83..ff88a6c278 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`SessionEventMap` 是磁盘格式的词汇,但其声明分散在所属 session 包和声明合并中。生成式持久化目录是所有事件、各自完整 payload 声明与源码 JSDoc,以及共享 `SessionEvent` 信封的唯一参考;手工维护的表格会发生漂移,因此被移除。这些记录不是 Cordis 事件——观察者通过唯一的 `session/event` 总线事件接收它们——所以 Cordis 目录无法覆盖。生成器会发现所有声明,文档同步新鲜度门禁会拒绝遗漏或陈旧输出。 +`SessionEventMap` 是磁盘格式的词汇,但其声明分散在所属的会话包(package)和声明合并中。生成式持久化目录是所有事件、各自完整 payload 声明与源码 JSDoc,以及共享 `SessionEvent` 信封的唯一参考;手工维护的表格会发生漂移,因此被移除。这些记录不是 Cordis 事件——观察者通过唯一的 `session/event` 总线事件接收它们——所以 Cordis 目录无法覆盖。生成器会发现所有声明,文档同步新鲜度门禁会拒绝遗漏或陈旧输出。 ## 决策 @@ -21,12 +21,12 @@ Status: implemented - **专用围栏。** 声明块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 会识别并跳过这些块,将其排除在退出检查比例之外——处理方式与 `ts cordis-catalog` 相同(这些声明引用所属模块中的类型,无法独立编译)。 - **仓库范围。** 目录枚举本仓库中的包,与兄弟文档的 packages-only 范围一致;下游插件可以合并更多事件类型,它们在设计上不在目录范围内。遍历过程用硬错误保护自身假设:拥有方的顶层 `interface SessionEventMap` 必须是 `@deepseek-ai/dsh-session` 中唯一的导出声明(无关的、局部的或同名重复的接口不能被当作磁盘词汇编入目录);任何声明不得携带 `extends`(继承的键会加入 `keyof SessionEventMap` 却没有对应的目录行);每个成员必须是带有显式 payload 类型的属性签名(方法形式的成员会加入 `keyof` 却在静默遍历中被漏过);跨声明的重复成员也会失败。 -本方案取代了手工副本:session.md 的 `hook/*` 表格、精简版 README 的事件表格、hook-protocol README 的 payload 条目列表,以及 session README 的名称列表现在链接到目录,而不再重述 payload(周围的语义说明文字保留原位)。hook-protocol 合并成员上的两个误加的 `@mode emit` 标签已被移除——新门禁将它们作为类别错误拒绝。 +本方案取代了手工副本:session.md 的 `hook/*` 表格、精简版 README 的事件表格、hook-protocol README 的 payload 条目列表,以及会话 README 的名称列表现在链接到目录,而不再重述 payload(周围的语义说明文字保留原位)。hook-protocol 合并成员上的两个误加的 `@mode emit` 标签已被移除——新门禁将它们作为类别错误拒绝。 ## 曾考虑的替代方案 - **基于启动的生成器(类似工具目录)**:日志词汇完全是静态的,AST 遍历无需启动任何东西即可读取全部真相。 -- **保留手工副本**:手工副本只能检查作者已经写下的名称;目录落地时,session README 的合并说明已经漂移。 +- **保留手工副本**:手工副本只能检查作者已经写下的名称;目录落地时,会话 README 的合并说明已经漂移。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml index ddd9099b22..1cf70223f5 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-05-uniform-agent-note-format.md: 06082251c1b96c90ed470d84224662e00e29791b -2026-07-05-uniform-agent-note-format.zh.md: 61ade75f179d64fa73390dc85bcd47c30deb112a +2026-07-05-uniform-agent-note-format.zh.md: df6b0f4dfacf122f452807491680091827f69c25 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md index 61ade75f17..df6b0f4dfa 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.zh.md @@ -16,7 +16,7 @@ Agent Note(agent 决策记录)的路径编码了生命周期和类别,但 ## 曾考虑的替代方案 -- **完整的刚性模板**(每个生命周期使用固定章节顺序,重构每份 Agent Note 以适配):否决。大型设计 Agent Note 包含八到十五个定制技术章节(包拓扑、线协议、schema),它们是承载设计的内容,而非漂移;刚性顺序会迫使我们现在进行破坏性改写,并永远与模板较劲。 +- **完整的刚性模板**(每个生命周期使用固定章节顺序,重构每份 Agent Note 以适配):否决。大型设计 Agent Note 包含八到十五个定制技术章节(包(package)拓扑、线协议、schema),它们是承载设计的内容,而非漂移;刚性顺序会迫使我们现在进行破坏性改写,并永远与模板较劲。 - **仅规范化头部**(H1 和 Status,正文不动):否决。债务标记指出的是*正文*的体裁分裂,让 `Context`/`Decision` 与 `Problem`/`Proposal` 无限期并存什么也解决不了。 - **不设 Status 行**(文件夹已经表示状态;格式制定前最新的三份 Agent Note 及其中一份的中文对应文件省略了该行):否决,保留文件的自描述性。通过门禁校验该行与文件夹一致,消除了原本促使我们删除它的漂移风险。 - **带日期的 Status**(`Status: implemented (accepted YYYY-MM-DD)`):否决。接受日期属于叙述性历史,写作规则将其排除在文档之外;文件名承载首次提出日期,git 承载其余信息;门禁能检查日期格式,但永远无法检查其真实性。 diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml index 728dd62166..17473bf069 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-generated-config-catalog.md: f39f5138526d3278e839ee0053d5051bb8bc1c36 -2026-07-06-generated-config-catalog.zh.md: 8f08a7791916351df508c89f0c783dee2db17bae +2026-07-06-generated-config-catalog.zh.md: 825046914dad8e1a7d87340a310b252f03cbecb9 diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md index 8f08a77919..825046914d 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -仓库此前没有以源码为后盾的插件配置参考。各 package 的 README 对字段的记录方式不一致,未列举哪些包可被加载,也未校验运行时 schema 与声明的配置类型是否一致。 +仓库此前没有以源码为后盾的插件配置参考。各包(package)的 README 对字段的记录方式不一致,未列举哪些包可被加载,也未校验运行时 schema 与声明的配置类型是否一致。 ## 决策 `scripts/gen-config-catalog.ts` 根据各插件声明的 config 类型和 JSDoc 生成 [docs/config-catalog.md](../../../../docs/config-catalog.md),并包含注入要求、被引用类型的链接和源码位置。包内类型会以传递方式纳入;workspace 类型和外部类型则会链接或点名。确定性的 `--write` 和 `--check` 模式使提交页面成为生成产物。 -此处采用纯 AST 生成是正确的,原因与 events/services catalog 相同,而与 tool catalog 不同:配置类型是静态声明,仓库中每个 schemastery schema 都是静态的 `z.object`/`z.intersect` 字面量,因此源码即全部真相——配置表面没有任何部分是运行时组合的。 +此处采用纯 AST 生成是正确的,原因与事件/服务目录相同,而与工具目录不同:配置类型是静态声明,仓库中每个 schemastery schema 都是静态的 `z.object`/`z.intersect` 字面量,因此源码即全部真相——配置表面没有任何部分是运行时组合的。 具体选择: - **配置类型是第二参数的类型。** catalog 记录的是 `apply(ctx, config)` / 服务构造函数 `(ctx, config)` 的声明参数类型——即 Cordis 实际传入的值——而非按命名约定定位的 `Config` 导出。这使得遍历是全量的:无论接口叫 `AcpConfig` 还是 `BasicCompactConfig`,无论类型声明在兄弟文件中,还是插件完全没有验证 schema,都能正常工作。 -- **分类是全量的。** 每个 `packages/<group>/<pkg>` 条目都会被解析(镜像 Loader 的 `unwrapExports`:`exports.default ?? exports`),归入可配置插件、无配置插件、抽象 seam 类或库之一——各自渲染在独立小节中——无法归类的条目直接报错。新 package 不可能被悄悄遗漏。 +- **分类是全量的。** 每个 `packages/<group>/<pkg>` 条目都会被解析(镜像 Loader 的 `unwrapExports`:`exports.default ?? exports`),归入可配置插件、无配置插件、抽象 seam 类或库之一——各自渲染在独立小节中——无法归类的条目直接报错。新包不可能被悄悄遗漏。 - **逐字段 JSDoc 强制要求。** 粘贴的声明中每个属性(包括嵌套的类型字面量)都需要非空的 JSDoc 描述,否则生成失败。粘贴本身就是文档,因此这与 events catalog 通过 `@mode` 施加的强制函数相同:源码文档过于单薄时门禁报错,而非产出单薄的 catalog。 - **Schema 键与声明类型做比对。** 生成器通过局部和 workspace 类型解析嵌套的对象与数组路径。确定缺失的路径报错;无法枚举的外部或动态形状则跳过。比对有意设计为单向的,因为声明类型可能包含被排除在 loader 配置之外的运行时专用字段。 - **专用围栏。** 粘贴的声明使用 ` ```ts config-catalog ` 信息字符串,`doc-typecheck` 会跳过它(引用了导入类型的孤立声明无法独立编译),并将其排除在 opt-out 比例之外——与 `cordis-catalog` 和 `persistence-catalog` 围栏的处理方式相同。 - **单文件 `docs/config-catalog.md`**,而非一个单文件目录:该页面面向单一受众(`cordis.yml` 的编写者),只有一个维度,不同于 `cordis-catalog/`(其中包含两个并列页面)。 -各 package README 中的 `## Config` 小节保留。重叠是有意接受的:README 是经过策划的逐包契约(在部署上下文中描述配置语义,连同限制与扩展点),catalog 则是穷举式的生成枚举。由于 catalog 是生成的,二者不一致时说明 README 有误,修复方式是编辑 README——catalog 不会漂移。 +各包 README 中的 `## Config` 小节保留。重叠是有意接受的:README 是经过策划的逐包契约(在部署上下文中描述配置语义,连同限制与扩展点),catalog 则是穷举式的生成枚举。由于 catalog 是生成的,二者不一致时说明 README 有误,修复方式是编辑 README——catalog 不会漂移。 ## 曾考虑的替代方案 - **合成式逐字段渲染**:为每个字段生成项目符号列表、表格或带注释的 YAML 片段,从解析的 JSDoc 加 schema 元数据组装。否决,改用逐字粘贴:接口连同其 JSDoc 本身就是以原始形式撰写的契约,合成渲染器会重新格式化它不拥有的行文,增加一个可能歪曲原意的渲染层。 -- **运行时启动 + schema 内省(如 tool catalog 所做的那样)**:否决。此处没有任何内容是运行时组合的,且 schema 本身对配置表面的文档化不足(以行文记录的默认值、运行时专用字段、完全没有 schema 的插件)。启动只会增加脆弱性而不增加真相。 +- **运行时启动 + schema 内省(如工具目录所做的那样)**:否决。此处没有任何内容是运行时组合的,且 schema 本身对配置表面的文档化不足(以行文记录的默认值、运行时专用字段、完全没有 schema 的插件)。启动只会增加脆弱性而不增加真相。 - **双向 schema/接口等价检查**:否决,改用子集检查。声明类型合理地包含 schema 拒绝从配置接受的成员(运行时专用 seam)。 - **在同一变更中废除 README `## Config` 小节**:否决。保留可接受的重叠使逐包契约在原处可读,而清理工作需要先把每个 README 的额外事实折入字段 JSDoc——这是可分离的工作,catalog 不依赖它。 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index bb788d803d..cabb6daf05 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd -2026-07-06-node-engine-floor.zh.md: 18e878bdfcc32938125ee46e742a42499e0b58ab +2026-07-06-node-engine-floor.zh.md: 266ae0fa30729884d5c914631d426393676255a1 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index 18e878bdfc..266ae0fa30 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -根 `engines.node` 范围中的 Node 22 分支是对已安装工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。它不得低于工作区在该分支上安装的依赖所声明的 package `engines.node`;否则 `pnpm install --engine-strict` 会在一个已宣传的 LTS 版本上失败,而非严格模式的安装则会在依赖所支持的运行时范围之外运行。 +根 `engines.node` 范围中的 Node 22 分支是对已安装工作区的契约,而不仅仅是 harness 源码直接调用的运行时 API 的契约。它不得低于工作区在该分支上安装的依赖包(package)所声明的 `engines.node`;否则 `pnpm install --engine-strict` 会在一个已宣传的 LTS 版本上失败,而非严格模式的安装则会在依赖所支持的运行时范围之外运行。 ## 决策 @@ -17,7 +17,7 @@ Status: implemented - **`node:sqlite`**:`packages/session-persistence/session-persistence-sqlite` 在顶层执行 `import { DatabaseSync } from 'node:sqlite'`。该模块在 **22.13**(LTS)和 **23.4**(Current)取消了 `--experimental-sqlite` 标志要求;在此之前,导入它会在加载时抛出异常。 - **原生 TypeScript 类型剥离**——构建模式的 `examples/headless-agent/tests/keyless-smoke.e2e.ts` 冒烟测试使用纯 `node`(无 tsx)启动 `dsh-cli-demo` 已发布的 `lib/bin.js`,并加载示例的 `.ts` 测试适配器(`cli-mock-llm.ts`)。类型剥离从 **22.18**(LTS)和 **23.6**(Current)起成为默认行为;更早版本需要 `--experimental-strip-types`。 -这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的 package 声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 +这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的包声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 `@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:使用 Node 23+/24+/25+ 的 API 会在所有机器和类型检查门禁中导致 `tsc` 失败,而不是编译通过、直到仅下限矩阵分支才能捕获的运行时错误才暴露。目前整个代码树在 Node 22 类型表面上类型检查全部通过,因此这一固定没有任何代价。 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml index 8986227bd3..9aa8a03c52 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-parallel-github-ci-gates.md: 5c276f6a75936021369bc5ad9494c9aa6e4e3fc3 -2026-07-06-parallel-github-ci-gates.zh.md: 6f46dd79e41b46d29fd4ed9f98009e0503f1be04 +2026-07-06-parallel-github-ci-gates.zh.md: 7d98f842ef1d60a3a5b727f975cb1d93ea6f253c diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md index 6f46dd79e4..7d98f842ef 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -无密钥 GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照重放、构建、包发布卫生、demo 冒烟和已构建二进制冒烟会因不同原因失败,也不需要彼此的运行时状态。将它们作为一条有序命令链运行,会使工作流墙钟时间等于所有门禁耗时之和;而把每个短小叶子拆成独立 GitHub job,又会反复执行 checkout、Node 设置、pnpm 恢复和安装,直到编排开销成为瓶颈。 +无密钥 GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照重放、构建、包(package)的发布卫生检查、demo 冒烟和已构建二进制冒烟会因不同原因失败,也不需要彼此的运行时状态。将它们作为一条有序命令链运行,会使工作流墙钟时间等于所有门禁耗时之和;而把每个短小叶子拆成独立 GitHub job,又会反复执行 checkout、Node 设置、pnpm 恢复和安装,直到编排开销成为瓶颈。 随着 workspace 增长,原有的宽车道拆分不再满足这一平衡。PR(Pull Request)#404 合并时,Linux 的静态、覆盖率、快照和产物 job 分别耗时 148、195、94 和 230 秒;Windows 的静态和产物 job 分别耗时 251 和 482 秒。每个包都调用一次包管理器打包,主导了两个产物验证器的耗时;覆盖率在仅运行源码的套件前无谓地重建输出;CPU 密集型门禁则在静态与覆盖率车道内争用资源。 @@ -26,7 +26,7 @@ Status: implemented 产物使用两个车道:一个元数据车道负责 `publint`、NodeNext 声明和已编译不变量加载,另一个负责已构建二进制冒烟。每个车道都会在其消费方之前自行构建。重复短时构建会消耗 runner 分钟数,但避免了上传/下载依赖,并使每个 job 的关键路径保持有界。 -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 在进程内针对内存发布视图调用 publint 支持的 API;该视图由每份清单声明的文件和 npm 强制元数据文件构成。这样无需生成 103 次包管理器打包命令,也能保留 workspace 文件与已发布文件之间的区别。[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) 在真实包下暂存这些经过结构验证、由清单声明的 `lib/` 文件,再通过纯 Node 和 Cordis Loader 规范化导入已编译的自引用。若伴随项触及未声明的运行时 chunk,仍会失败。 +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 在进程内针对内存发布视图调用 publint 支持的 API;该视图由每份清单声明的文件和 npm 强制元数据文件构成。这样无需生成 103 次包管理器打包命令,也能保留 workspace 文件与已发布文件之间的区别。[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) 在真实包下暂存这些经过结构验证、由清单声明的 `lib/` 文件,再通过纯 Node 和 Cordis Loader 规范化导入已编译的自引用。若伴随项触及未声明的运行时分片,仍会失败。 兼容性车道会在每条声明支持的 Node 版本线上运行源码 worker 和 Zstandard 运行时冒烟。TypeScript 在专用的主 Node 24 车道中只检查一次源码图;在运行时兼容性 job 中重复同一编译器分析只会增加耗时,不会提供运行时特有信号。 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index b228a3a455..0360eacf60 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-parallel-pre-push-gates.md: f2e8f0054e595be20a320ec7095f0fe674eb93c6 -2026-07-06-parallel-pre-push-gates.zh.md: 06cd2fc41ca9a2cc2546fe85de82c8198f9573c3 +2026-07-06-parallel-pre-push-gates.zh.md: 03b8773e475a9d1c82cea830cae6806a1c016f01 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 06cd2fc41c..03b8773e47 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-06-parallel-pre-push-gates.md) | 中文 -本记录中的本地 hook 部分已由[快速本地 Git hook](2026-07-22-fast-local-git-hooks.md) 取代。有界门禁调度器和包级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。 +本记录中的本地 hook 部分已由[快速本地 Git hook](2026-07-22-fast-local-git-hooks.md) 取代。有界门禁调度器和包(package)级 `publint` 并行机制仍用于 CI、`doc-sync` 和显式本地命令。 ## 问题 diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml index 4c43b4d233..f24a929889 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-10-readme-known-limitations-gate.md: 2ca1168d795692730d17b6ab23dd113e8be277e5 -2026-07-10-readme-known-limitations-gate.zh.md: a64f15d9c6f1b48413e06d2a46b33f6f228f2f02 +2026-07-10-readme-known-limitations-gate.zh.md: 4e42492f501cca1a45a90694acea4ca78e920780 diff --git a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md index a64f15d9c6..4e42492f50 100644 --- a/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 在每个 package README 中设置受门禁保护的 Known Limitations 章节 +# Agent Note: 每个包(package)README 中受门禁保护的 Known Limitations 章节 Status: implemented @@ -26,4 +26,4 @@ Status: implemented - 新建的包须声明符合条件的限制事项,或显式加入白名单;缺失、漂移或空的章节会在本地和 CI 的 `doc-sync` 中失败。 - 门禁为 `doc-sync` 新增一个无外部依赖的 TypeScript 脚本。 -- 重命名受强制的标题需要同时修改脚本和所有 package README。 +- 重命名受强制的标题需要同时修改脚本和所有包 README。 diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml index 5c2bde0225..ab312d8bcd 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-package-model-experience-contract.md: 92a8e5a1a81d00dae085e4af89456896373058e6 -2026-07-12-package-model-experience-contract.zh.md: e7a8db794e3e83021fdcab8fc94c4a4f67abb842 +2026-07-12-package-model-experience-contract.zh.md: 54b181738b8276c634f777ad3424191c8652baec diff --git a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md index e7a8db794e..54b181738b 100644 --- a/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md +++ b/.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Package Model Experience 契约 +# Agent Note: 包(package)的模型体验契约 Status: implemented @@ -6,28 +6,28 @@ Status: implemented ## 问题 -包 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的问题:该包的哪些内容会进入模型请求、在什么条件下进入、这些 token 会保留多久,以及后续请求是否会保留可复用的 KV cache 前缀。在插件架构中,这种遗漏尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能以错误取代成功结果,压缩可能移除旧历史,而 agent 范围的注册可能改变某个 agent 的 prompt 或 schema,却不影响其他 agent。因此,只阅读名义上面向模型的包会遗漏真实的上下文效应,而在每次常规评审中跨所有依赖阅读源码又成本过高。 +包 README 可以解释 API 和运行时机制,却不回答主导 agent harness(智能体框架)行为与成本的问题:该包的哪些内容会进入模型请求、在什么条件下进入、这些 token 会保留多久,以及后续请求是否会保留可复用的 KV cache 前缀。在插件架构中,这种遗漏尤其难以审计。消费方可能把后端结果转为工具消息,策略插件可能以错误取代成功结果,压缩可能移除旧历史,而 agent 范围的注册可能改变某个 agent 的提示词或 schema,却不影响其他 agent。因此,只阅读名义上面向模型的包会遗漏真实的上下文效应,而在每次常规评审中跨所有依赖阅读源码又成本过高。 ## 决策 每个具有面向模型或邻近模型契约的 workspace 包 README 都以规范的[模型体验章节](../../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme)收尾,位置紧邻 `## Known Limitations and Deferred Work` 之前;位于“无限制项”允许列表中的包则以模型体验本身结尾。经审计确认与模型无关的通用包通过 `NO_MODEL_EXPERIENCE_SECTION` 省略该章节。 -具有直接、条件式、有上限、全生命周期、多表面或辅助模型效应的包,为每个上下文表面使用一个 H3。每个表面包含三个有序 H4 字段——`What the model sees`、`Token effect` 和 `KV Cache effect`——每个字段都以一个正文段落开头。cache 字段区分仅追加增长、稳定重复前缀、替换先前 token,以及独立模型请求;它点明由包拥有、且能在新内容追加前改变请求的每项配置、范围、生命周期、压缩或路由变化。“Does not invalidate”表示该包保留一个已经可复用的前缀,并非承诺 provider 一定命中 cache 或保留某段时间。由包拥有的稳定文本按原文精确引用:system prompt 正文和其他长字面量在引入它们的字段下使用带标题的 H5 加 `markdown` 围栏,通常位于 `What the model sees`;短字面量则以内联形式保留,并点名插值占位符。工具 schema 表面链接生成式[工具目录](../../../../docs/tool-catalog.md)中带锚点的章节,并且只陈述组合或配置增量;仅运行时定义解释目录为何省略它们。依赖数据和由 provider 拥有的文本采用摘要。agent 范围的可见性须显式说明;当范围可隐藏 prompt 与 schema 中的一者而不影响另一者时,两种表面保持分离。 +具有直接、条件式、有上限、全生命周期、多表面或辅助模型效应的包,为每个上下文表面使用一个 H3。每个表面包含三个有序 H4 字段——`What the model sees`、`Token effect` 和 `KV Cache effect`——每个字段都以一个正文段落开头。cache 字段区分仅追加增长、稳定重复前缀、替换先前 token,以及独立模型请求;它点明由包拥有、且能在新内容追加前改变请求的每项配置、范围、生命周期、压缩或路由变化。“Does not invalidate”表示该包保留一个已经可复用的前缀,并非承诺提供方一定命中 cache 或保留某段时间。由包拥有的稳定文本按原文精确引用:系统提示词正文和其他长字面量在引入它们的字段下使用带标题的 H5 加 `markdown` 围栏,通常位于 `What the model sees`;短字面量则以内联形式保留,并点名插值占位符。工具 schema 表面链接生成式[工具目录](../../../../docs/tool-catalog.md)中带锚点的章节,并且只陈述组合或配置增量;仅运行时定义解释目录为何省略它们。依赖数据和由提供方拥有的文本采用摘要。agent 范围的可见性须显式说明;当范围可隐藏提示词与 schema 中的一者而不影响另一者时,两种表面保持分离。 -没有模型上下文效应的包,或某条路径完全由另一个包渲染的包,使用验证器审计过的短格式:一句以 `None, as ` 或 `Indirectly, through ` 开头的句子,随后是一个 `KV Cache effect` H4 和一个正文段落。纯传输包和无密钥测试支持包若不创建任何进入模型的内容,就使用 none 格式。provider 后端即使会限制或过滤数据也使用 indirect 格式;具名子项拥有全部效应时,接线 bundle 也使用该格式。这些章节定位贡献并声明不会直接使 cache 失效,同时不重复陈述消费方。结构化章节同样只记录由包拥有的输入、变换和增量。 +没有模型上下文效应的包,或某条路径完全由另一个包渲染的包,使用验证器审计过的短格式:一句以 `None, as ` 或 `Indirectly, through ` 开头的句子,随后是一个 `KV Cache effect` H4 和一个正文段落。纯传输包和无密钥测试支持包若不创建任何进入模型的内容,就使用 none 格式。提供方后端即使会限制或过滤数据也使用 indirect 格式;具名子项拥有全部效应时,接线 bundle 也使用该格式。这些章节定位贡献并声明不会直接使 cache 失效,同时不重复陈述消费方。结构化章节同样只记录由包拥有的输入、变换和增量。 `verify-package-readme-model-experience` 发现包清单,并验证三种分类、规范末尾章节顺序、确切字段标题深度与顺序、非空字段段落、逐字块的 H5 归属、具体字面量证据,以及带锚点的工具目录链接。它在 `doc-sync` 和并行门禁 runner 中运行。评审仍负责覆盖面、链接相关性和事实准确性。 ## 曾考虑的替代方案 -- **只记录注册提示词或工具的 package**:否决。后端、策略插件、适配器、持久化、作用域和压缩都会改变 token 的内容或生命周期,却不拥有面向模型的 schema。 -- **从源码生成一份集中式上下文成本目录**:否决。AST 能找到注册点,但无法推断语义条件,如历史保留、输出截断、父子可见性或辅助模型边界。package README 是实现本地的契约;集中副本会增加又一个漂移面。 +- **只记录注册提示词或工具的包**:否决。后端、策略插件、适配器、持久化、作用域和压缩都会改变 token 的内容或生命周期,却不拥有面向模型的 schema。 +- **从源码生成一份集中式上下文成本目录**:否决。AST 能找到注册点,但无法推断语义条件,如历史保留、输出截断、父子可见性或辅助模型边界。包 README 是实现本地的契约;集中副本会增加又一个漂移面。 - **要求给出精确 token 数**:否决。精确数量取决于所选模型的 tokenizer、适配器序列化方式、配置和运行时数据。稳定的契约是增长形状:每请求固定、每调用条件性、保留、替换、有上限或零直接影响。 - **使用表格**:否决。精确源码文本和条件式结果形状会使单元格密集而难以扫读。重复的小节在保留相同字段的同时,为每个上下文表面提供易读的纵向空间。 -- **允许所有零影响 package 省略该章节**:否决。无约束的缺失在「经审计的零影响」和「忘记写文档」之间有歧义。省略仅限于在验证器中以理由命名的模型无关通用 package;模型相邻的零影响 package 保留一句显式说明。 +- **允许所有零影响包省略该章节**:否决。无约束的缺失在「经审计的零影响」和「忘记写文档」之间有歧义。省略仅限于在验证器中以理由命名的模型无关通用包;模型相邻的零影响包保留一句显式说明。 - **要求经审计的零效应包或简单间接包使用完整结构化格式**:否决。它会围绕一个事实重复标签。受门禁约束的句子加 cache 字段既保留显式覆盖,又没有多余仪式。 -- **只有约定而无门禁**:否决。仓库级契约必须覆盖未来的每个 package;评审者的记忆无法可靠地检测到遗漏的 README 章节。 +- **只有约定而无门禁**:否决。仓库级契约必须覆盖未来的每个包;评审者的记忆无法可靠地检测到遗漏的 README 章节。 ## 后果 -评审者可以从任何面向模型或邻近模型的包开始,看到它对对话模型、子模型和辅助调用的贡献,无需重建完整插件图。token 预算工作可以区分重复请求开销和依赖数据的历史,而 cache 敏感工作可以识别仅追加路径,以及最早由包引起的前缀变更。agent 范围变更有明确的文档检查点。每当模型可见行为发生变化时,包作者都要维护一个或多个紧凑的上下文表面块,或一种已分类的短格式;经审计的通用包不携带无关的模型样板。结构化字段不承诺 provider 精确的 token 数或 cache 命中;测量仍取决于模型、provider 和工作负载,而所记录的增长、可见性和前缀稳定性契约保持稳定。 +评审者可以从任何面向模型或邻近模型的包开始,看到它对对话模型、子模型和辅助调用的贡献,无需重建完整插件图。token 预算工作可以区分重复请求开销和依赖数据的历史,而 cache 敏感工作可以识别仅追加路径,以及最早由包引起的前缀变更。agent 范围变更有明确的文档检查点。每当模型可见行为发生变化时,包作者都要维护一个或多个紧凑的上下文表面块,或一种已分类的短格式;经审计的通用包不携带无关的模型样板。结构化字段不承诺由提供方给出的精确 token 数或 cache 命中;测量仍取决于具体模型、提供方和工作负载,而所记录的增长、可见性和前缀稳定性契约保持稳定。 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml index caebea33ff..bf29a5ddca 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-drop-mutable-session-summary.md: 80fe043e365352b17d2a5b3efa1ab8d396d311c4 -2026-06-19-drop-mutable-session-summary.zh.md: e2326c21681eaa7d0a325264c24f188379ec4b21 +2026-06-19-drop-mutable-session-summary.zh.md: e875d96d003367dbe8a671e6057a6b4a6a44ca74 diff --git a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md index e2326c2168..e875d96d00 100644 --- a/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md @@ -22,7 +22,7 @@ Status: implemented 摘要原本要提供的一切,在消费方真正需要时都**可从仅追加日志中派生**(`firstPrompt` = 第一条 `user/message`;近期度 = 最后一个事件的 `time` 或文件 mtime),或者已经存在于不可变 header 中(`createdAt`、`cwd`)。唯一*不可*派生的是用户*手动编辑*的标题,但它从未实现,纯属 YAGNI;如果未来真有功能需要,它可以作为独立的日志事件或 header 字段回归。 -这被记录为一项决策,因为它具有**持久性**(它同时收窄两个后端的公共服务契约和磁盘格式)、**争议性**(summary 是有意为未来设计的结果,而非意外),也具有**意外性**(未来读者在原 Agent Note(agent 决策记录)描述 `SessionMeta` 的位置发现 `SessionHeader`,否则会追问 summary 为何消失)。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md)扫清障碍:不再有可变 summary 后,协调器的 hook 接口不需要 `updateSummary` hook,JSONL sidecar 与 SQLite 列之间的持久性分歧也随之消失,使两个后端的写入路径趋于一致。 +这被记录为一项决策,因为它具有**持久性**(它同时收窄两个后端的公共服务契约和磁盘格式)、**争议性**(summary 是有意为未来设计的结果,而非意外),也具有**意外性**(未来读者在原 Agent Note(agent 决策记录)描述 `SessionMeta` 的位置发现 `SessionHeader`,否则会追问 summary 为何消失)。它还为[共享持久化写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md)扫清障碍:不再有可变 summary 后,协调器的钩子接口不需要 `updateSummary` 钩子,JSONL sidecar 与 SQLite 列之间的持久性分歧也随之消失,使两个后端的写入路径趋于一致。 ## 无需迁移 diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml index 8e123cc73d..e30531e02d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-collapse-trace-only-session-events.md: fce5c48ef6fcb1abc6e2fbb95dc7e83d22956660 -2026-06-20-collapse-trace-only-session-events.zh.md: 48a63cdae1217e8244b66603db8b5501239cb9f4 +2026-06-20-collapse-trace-only-session-events.zh.md: 5be2d77644c7b4084b892013a2c1e5b97a333d74 diff --git a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md index 48a63cdae1..5be2d77644 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md @@ -37,7 +37,7 @@ Status: implemented 按提案落地,但有一处范围细化(遵循 AGENTS.md 所述“Agent Note(agent 决策记录)是提案,而非绝对真理”): -- **空内容 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片无处安放)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),以前会发出独立的 `usage`。现在它记录一个空内容的 `assistant/message { content: [], usage }`。为防止这向 provider transcript 注入一个无内容的虚假 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。回归测试断言 usage 仍被表示,且派生历史未被破坏。 +- **空内容 `assistant/message` 承载 usage,无数据丢失。** 提案要求的证明(不会有已持久化的 usage 分片无处安放)落在 max-tokens 路径上:一个被截断的步骤有 usage 但内容为空(例如只有一个被丢弃的工具调用),以前会发出独立的 `usage`。现在它记录一个空内容的 `assistant/message { content: [], usage }`。为防止这向提供方 transcript 注入一个无内容的虚假 assistant 轮次,`deriveMessages()` 跳过空内容的 `assistant/message` 事件。回归测试断言 usage 仍被表示,且派生历史未被破坏。 **格式版本。** 此变更影响已持久化的事件,但预发布会话格式仍固定为 `0`,拒绝任何其他版本且不做迁移。`dsh-session` 拥有写入方和加载校验使用的常量。单调递增的格式版本从首次正式发布开始。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml index 0a33c09edb..9529343206 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unconsumed-llm-adapter-change-event.md: a3c7c089d7dfa1a4cd6a891c416bf270dc7eff3d -2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: b0dc96897d14a032db857ee820d082afbea8f6a6 +2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md: d9129386dc95bae0716253fdf50236b25ebfdf75 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md index b0dc96897d..d9129386dc 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md @@ -8,25 +8,25 @@ Status: implemented `LlmService.registerAdapter()` 在注册和 dispose(资源释放)时发出 `llm/adapter-change` 事件([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts))。在 `packages/*/src` 和 `examples/*/src` 中搜索 `llm/adapter-change`,只能找到声明、emit 站点、文档和测试;没有任何生产环境的监听器订阅它。 -这与 `tools/change` 和 `system-prompt/change` 不同。如今这两个事件同样没有消费方,但它们有望成为未来实时工具/prompt UI 的注册表变更信号。LLM(大语言模型)adapter 注册更像是启动时的实现细节:adapter 不是用户可见的选项面板,真正的模型调用拦截 seam 是 `llm/stream`。保留一个没有监听器的 adapter 变更事件,只是在更小范围内重复[删除无用 summary](2026-06-19-drop-mutable-session-summary.md) 的模式。 +这与 `tools/change` 和 `system-prompt/change` 不同。如今这两个事件同样没有消费方,但它们有望成为未来实时工具/提示词 UI 的注册表变更信号。LLM(大语言模型)适配器注册更像是启动时的实现细节:适配器不是用户可见的选项面板,真正的模型调用拦截 seam 是 `llm/stream`。保留一个没有监听器的适配器变更事件,只是在更小范围内重复[删除无用 summary](2026-06-19-drop-mutable-session-summary.md) 的模式。 这个事件并非零成本。`registerAdapter()` 在发出 `llm/adapter-change` 之前先 yield 回滚 disposer,这样抛出异常的监听器会回退变更而非泄漏适配器条目;包内还有针对该监听器抛出路径的测试。这种防御性排序保护的是一个只有测试才能触发的失败模式。 ## 决策 -只移除 `llm/adapter-change`:包括 `dsh-llm` 的 `interface Events` 中的声明、`ctx.emit('llm/adapter-change')` 调用,以及 `LlmService.registerAdapter` JSDoc 中“在注册和释放时发出 `llm/adapter-change`”的句子。`registerAdapter()` 的效应生成器为 HMR(热模块替换)/释放保留变更与回滚 disposer,但移除仅因该事件而存在的监听器抛错回滚顺序。adapter disposer 测试断言返回的 disposer 会移除 adapter,不再订阅事件;监听器抛错回滚测试则随其测试对象一起消失。[docs/architecture.md](../../../../docs/architecture.md) 和 [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) 中的事件分类也在同一变更中更新。 +只移除 `llm/adapter-change`:包括 `dsh-llm` 的 `interface Events` 中的声明、`ctx.emit('llm/adapter-change')` 调用,以及 `LlmService.registerAdapter` JSDoc 中“在注册和释放时发出 `llm/adapter-change`”的句子。`registerAdapter()` 的效应生成器为 HMR(热模块替换)/释放保留变更与回滚 disposer,但移除仅因该事件而存在的监听器抛错回滚顺序。适配器 disposer 测试断言返回的 disposer 会移除适配器,不再订阅事件;监听器抛错回滚测试则随其测试对象一起消失。[docs/architecture.md](../../../../docs/architecture.md) 和 [packages/llm/llm/README.md](../../../../packages/llm/llm/README.md) 中的事件分类也在同一变更中更新。 ## 曾考虑的替代方案 ### 为什么不移除所有注册表变更事件? -由注册表通告变更的微内核是一种一致的约定。当 UI 能够实时刷新可用工具或 prompt 章节时,`tools/change` 和 `system-prompt/change` 可能会有用。本 Agent Note(agent 决策记录)在存在合理用户侧消费方的位置保留该约定,只删除当前及可能的未来消费方都不明确的 adapter 变更事件。 +由注册表通告变更的微内核是一种一致的约定。当 UI 能够实时刷新可用工具或提示词章节时,`tools/change` 和 `system-prompt/change` 可能会有用。本 Agent Note(agent 决策记录)在存在合理用户侧消费方的位置保留该约定,只删除当前及可能的未来消费方都不明确的适配器变更事件。 如果将来需要 LLM 适配器浏览器或动态模型选择器用到此信号,届时再连同消费方一起重新引入,并提供比「something changed」更清晰的 payload。 ## 验证 -`llm/adapter-change` 及其 emit 已消失,重新生成的 Cordis 目录保持新鲜;HMR 安全性仍成立(释放贡献该 adapter 的 fiber 会移除它);`tools/change` 和 `system-prompt/change` 仍有文档与测试;ACP(Agent Client Protocol)快照和无密钥 Headless Loader 冒烟则固定了未变的生产路径。 +`llm/adapter-change` 及其 emit 已消失,重新生成的 Cordis 目录保持新鲜;HMR 安全性仍成立(释放贡献该适配器的 fiber 会移除它);`tools/change` 和 `system-prompt/change` 仍有文档与测试;ACP(Agent Client Protocol)快照和无密钥 Headless Loader 冒烟则固定了未变的生产路径。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml index b8c3abac63..ad842ae732 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unconsumed-llm-assembled-surfaces.md: b6b596e822b4bd6fd1bd891c336c622ad675ad45 -2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: 6ca867767a7b74218b974c17b4c14bc733e3cd7d +2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md: bafc5d3bc630d89c776bbcf53719b29223e1c90d diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md index 6ca867767a..bafc5d3bc6 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md @@ -14,13 +14,13 @@ Status: implemented LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体循环),它只使用 `stream()`:将原始分片送入自己的 `BlockAssembler`,以便在并行组装的同时记录分片,保证回放保真度([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts),`ctx.llm.stream(req)` 步骤)。在 `packages/*/src` 和 `examples/*/src` 中 grep `streamBlocks` 与 `ctx.llm.generate`,找不到任何生产调用方。仅有的引用来自服务方法定义、文档和测试;适配器测试用 `generate()` 作为便捷驱动,但它们完全可以通过同一个 assembler 辅助函数手动消费 `stream()`,无需为此保留一个公开的生产 API。 -这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:带有受测契约的组装视图 API,由测试而非生产代码消费。它们是为不关心 token 级增量的消费方推测性构建的,但唯一的真实消费方恰恰关心增量,以便持久化高保真重放数据。 +这属于[删除可变会话 summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:带有受测契约的组装视图 API,由测试而非生产代码消费。它们是为不关心 token 级增量的消费方推测性构建的,但唯一的真实消费方恰恰关心增量,以便持久化高保真重放数据。 `streamBlocks()` 拖带了 `BlockAssembler` 的一块专用逻辑:`flushReady()` 与 `flushRemaining()`([packages/llm/llm/src/assembler.ts:138-168](../../../../packages/llm/llm/src/assembler.ts))以及 `flushed` 游标字段,仅为支持按序增量产出而存在。`generate()` 拖带了 `GenerateResult`、`BlockAssembler.result()` 以及 `llm/generate` waterfall——在同一底层流之上的第二个拦截面。agent loop 对 assembler 的使用仅限于 `push()` / `message()` / `usage` / `finish`,不涉及流式 flush 或一次性服务组装。 ## 决策 -`stream()` 是唯一的公开 LLM 调用接口。移除 `streamBlocks`、`generate`、其事件/结果类型,以及仅被该路径使用的 assembler 辅助方法。适配器测试通过本地辅助函数对公开的 stream 进行组装;`BlockAssembler` 仅保留有生产消费方的操作。 +`stream()` 是唯一的公开 LLM 调用接口。移除 `streamBlocks`、`generate`、其事件/结果类型,以及仅被该路径使用的 assembler 辅助方法。适配器测试通过本地辅助函数对公开流进行组装;`BlockAssembler` 仅保留有生产消费方的操作。 ## 曾考虑的替代方案 @@ -28,7 +28,7 @@ LLM(大语言模型)服务唯一的生产消费方是 agent loop(智能体 ## 验证 -`streamBlocks`、`generate`、`llm/generate` 及仅供它们使用的 assembler 辅助函数均已移除,且未产生新的无用导出;两个真实 adapter 都通过 `stream()` 和共享 assembler 接受测试;循环行为保持一致(ACP(Agent Client Protocol)快照预期输出未变);README、架构文档和模块文档也不再提及已删除表面。 +`streamBlocks`、`generate`、`llm/generate` 及仅供它们使用的 assembler 辅助函数均已移除,且未产生新的无用导出;两个真实适配器都通过 `stream()` 和共享 assembler 接受测试;循环行为保持一致(ACP(Agent Client Protocol)快照预期输出未变);README、架构文档和模块文档也不再提及已删除表面。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml index bcca5a0490..ed0b63a28d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-prune-dead-seam-methods.md: 70596d908bb1d7559e876d93bce0e25874ce1ff0 -2026-06-20-prune-dead-seam-methods.zh.md: 0953eaa57dc9b490a7399a412338fc21a06a0ad2 +2026-06-20-prune-dead-seam-methods.zh.md: 1fbc7dbf2f7abb5b17b1df10ef06e490b11335a4 diff --git a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md index 0953eaa57d..1fbc7dbf2f 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 从 persistence seam 中移除无用方法 +# Agent Note: 从持久化 seam 中移除无用方法 Status: implemented @@ -12,32 +12,32 @@ Status: implemented ### `SessionPersistence.has()` 与 `.delete()` -该抽象服务在 create/append 之外声明了更多操作:`load`、`list`、`has`、`delete`。`ctx.sessionPersistence` 的生产消费方只用了两个:agent loop(智能体循环)的恢复路径调用 `load()`([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)),ACP(Agent Client Protocol)桥接层为 `session/list` 调用 `list()`([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts))。在 `packages/*/src` 和 `examples/` 中 grep 所有 `sessionPersistence.*` / `persistence.*` 的使用,找不到对该服务的 `has(` 或 `delete(` 调用。`packages/ui/acp/src/index.ts` 中的 `.has(`/`.delete(` 调用作用于内存中的 `SessionStore` 和一个本地的 loading id `Set`,而非 persistence。`has`/`delete` 的唯一调用者是契约测试套件和各后端的 spec。 +该抽象服务在 create/append 之外声明了更多操作:`load`、`list`、`has`、`delete`。`ctx.sessionPersistence` 的生产消费方只用了两个:agent loop(智能体循环)的恢复路径调用 `load()`([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)),ACP(Agent Client Protocol)桥接层为 `session/list` 调用 `list()`([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts))。在 `packages/*/src` 和 `examples/` 中 grep 所有 `sessionPersistence.*` / `persistence.*` 的使用,找不到对该服务的 `has(` 或 `delete(` 调用。`packages/ui/acp/src/index.ts` 中的 `.has(`/`.delete(` 调用作用于内存中的 `SessionStore` 和一个本地的 loading id `Set`,而非持久化。`has`/`delete` 的唯一调用者是契约测试套件和各后端的 spec。 -`has()` 不仅未被使用:在 `loadStored(id)` 已负责持久化存在性检查的情况下,它仍增加了协调器的已跟踪/未跟踪探测和一个契约分支。`delete()` 则拖入每个后端都必须实现的 `deleteStored` 后端 hook。这属于[删除可变 session summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:契约测试覆盖了两者,但已发布代码从不会询问“这个 session 是否已持久化?”或删除某个 session。 +`has()` 不仅未被使用:在 `loadStored(id)` 已负责持久化存在性检查的情况下,它仍增加了协调器的已跟踪/未跟踪探测和一个契约分支。`delete()` 则拖入每个后端都必须实现的 `deleteStored` 后端钩子。这属于[删除可变会话 summary](2026-06-19-drop-mutable-session-summary.md) 的同类模式:契约测试覆盖了两者,但已发布代码从不会询问“这个会话是否已持久化?”或删除某个会话。 ## 决策 没有消费方使用的方法被移除——从抽象 seam、实现,以及仅为覆盖它们而存在的契约/spec 测试套件中移除: -- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` hook 均消失(jsonl 和 sqlite 都只是为了满足该 hook 才实现 `deleteStored`,这些实现也一并移除)。后端属于[双后端](../architecture/2026-06-14-session-persistence.md)设计,其他方面不在范围内;删除它们为没有消费方的 hook 所做的实现,是删除 hook 的一部分,而非重新设计后端。 -- 所有文档和源码注释引用都已更新为保留下来的四方法、仅含 `list()` 的契约——不仅包括字面上的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和“六个公共方法”的计数——涉及 seam 和后端 README、[docs/architecture.md](../../../../docs/architecture.md)、[session persistence](../architecture/2026-06-14-session-persistence.md) 与[写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md) Agent Note,以及协调器/后端 JSDoc。 +- `SessionPersistence.has()` / `.delete()` 已移除:抽象声明、协调器的 `has`/`delete`/`deleteCore`,以及 `PersistenceBackend.deleteStored` 钩子均消失(jsonl 和 sqlite 都只是为了满足该钩子才实现 `deleteStored`,这些实现也一并移除)。后端属于[双后端](../architecture/2026-06-14-session-persistence.md)设计,其他方面不在范围内;删除它们为没有消费方的钩子所做的实现,是删除钩子的一部分,而非重新设计后端。 +- 所有文档和源码注释引用都已更新为保留下来的四方法、仅含 `list()` 的契约——不仅包括字面上的 `has(`/`delete(`/`deleteStored` 拼写,还包括 `{@link has}`/`{@link delete}` JSDoc 链接和“六个公共方法”的计数——涉及 seam 和后端 README、[docs/architecture.md](../../../../docs/architecture.md)、[会话持久化](../architecture/2026-06-14-session-persistence.md)与[写入协调器](../architecture/2026-06-18-shared-persistence-write-coordinator.md) Agent Note,以及协调器/后端 JSDoc。 ## 曾考虑的替代方案 ### 为什么不以「seam 应当完整」为由保留? -「persistence seam 理应提供 delete」这种直觉是真实的——但它恰恰是预发布阶段所警惕的投机性完整([AGENTS.md](../../../../AGENTS.md):为正确的基础优化,而非为你并不拥有的假想调用者优化)。`delete()` 是一个方法,等消费方真正需要时再加回来即可:一个删除旧会话的会话管理 UI 会需要它——到那时再加,基于该 UI 的真实需求来设计(软删除?级联?确认?),而非现在猜测。 +「持久化 seam 理应提供 delete」这种直觉是真实的——但它恰恰是预发布阶段所警惕的投机性完整([AGENTS.md](../../../../AGENTS.md):为正确的基础优化,而非为你并不拥有的假想调用者优化)。`delete()` 是一个方法,等消费方真正需要时再加回来即可:一个删除旧会话的会话管理 UI 会需要它——到那时再加,基于该 UI 的真实需求来设计(软删除?级联?确认?),而非现在猜测。 在有活跃消费方的情况下重新添加一个 seam 方法,成本低且设计更优,因为消费方锚定了契约。在无人使用的情况下保留它,意味着每个实现(以及未来的每个后端)都必须实现和测试一个无实际作用的方法。 ## 验证 -`has`/`delete`/`deleteStored` 已从 persistence seam、实现和契约测试套件中移除,没有新增无用导出;剩余操作(`create`/`append`/`load`/`list`)未受影响,ACP `session/list` 和崩溃恢复行为完全一致;seam README 和 `docs/architecture.md` 仅列出存留的方法。 +`has`/`delete`/`deleteStored` 已从持久化 seam、实现和契约测试套件中移除,没有新增无用导出;剩余操作(`create`/`append`/`load`/`list`)未受影响,ACP `session/list` 和崩溃恢复行为完全一致;seam README 和 `docs/architecture.md` 仅列出存留的方法。 ## 后果 - **`delete()` 是产品最终会需要的操作。** 确实如此,但「最终」正是关键。现在删除、将来基于真实消费方重新添加,严格优于发布一份猜测的契约。两个后端各自减少了一个 `deleteStored` 实现,这是在本次范围之外的包中的有限改动。 -- **低耦合。** 移除局限于 persistence seam + 实现 + 测试;没有跨包消费方引用被移除的方法,因此除文档外没有涟漪效应。 +- **低耦合。** 移除局限于持久化 seam + 实现 + 测试;没有跨包消费方引用被移除的方法,因此除文档外没有涟漪效应。 规模不大,但它将 seam 从「实现必须为无人提供什么」恢复为「恰好是消费方使用的东西」。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index dd6d9f1aa1..15f88684be 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-public-agent-stop-surface.md: 81a21de30bfbc25688069efbffb21647889b1bdb -2026-06-20-public-agent-stop-surface.zh.md: 8b39646bb5d012fee20fac6765dcfdd22ffb93b6 +2026-06-20-public-agent-stop-surface.zh.md: 664be4ac783005c9f415e737c534ca2326a6a00b diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index 8b39646bb5..664be4ac78 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -36,4 +36,4 @@ Status: implemented ## 相关 -本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;静止观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、session 和 identity。 +本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;静止观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、会话和 identity。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 829017fe21..98bcf89301 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-agent-boundary-mirror-events.md: 8c5bb74f2347fe0269cbb9c6504137de761ab919 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: 8e659adb810fd2668de57a123cd53432f137b08a +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 782d07a01d41753409ab6fc3ef75921673bd5754 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index 8e659adb81..782d07a01d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -15,7 +15,7 @@ Status: implemented ## 问题 -循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费方在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择 session log,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费方;它已经从 `session/event` 渲染工具调用和结果。 +循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费方在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择会话日志,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费方;它已经从 `session/event` 渲染工具调用和结果。 这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 @@ -23,13 +23,13 @@ Status: implemented 将 `session/event` 作为唯一的实时边界/transcript(文本记录)流。需要渲染轮次、工具调用、工具结果、助手消息和持久化边界的消费方统一订阅 `session/event`,从持久化层使用的同一套事件词汇中派生 UI。 -四个持久边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——已从 agent(智能体)事件分类中移除。希望在边界处取得 agent handle 的 UI 会保留来自 `agent/created`/`agent/disposed` 的实时目标对象,并直接比较其 session;`dsh-ui-stdio` 据此为应用拥有的 agent 标记 `[main turn N]` 头部,其他 session 则渲染其持久 id。规范记录仍是事件溯源 session log。 +四个持久边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——已从 agent(智能体)事件分类中移除。希望在边界处取得 agent handle 的 UI 会保留来自 `agent/created`/`agent/disposed` 的实时目标对象,并直接比较其会话;`dsh-ui-stdio` 据此为应用拥有的 agent 标记 `[main turn N]` 头部,其他会话则渲染其持久 id。规范记录仍是事件溯源会话日志。 步骤镜像(完全没有消费方)最先在[事件域语义 Agent Note(agent 决策记录)](../architecture/2026-06-30-event-domain-semantics.md) 中移除;该 Agent Note 当时以 stdio UI 需要在轮次边界取得 `Agent` handle 为由,保留了轮次镜像。本 Agent Note 完成余下工作:`dsh-ui-stdio` 是可随时丢弃的测试 REPL,其渲染可以自由变化,因此“ui-stdio 需要它”并不是保留镜像的理由——它读取 `session/event`,只保留自己的实时目标对象。 ## 范围:移除什么、不移除什么 -已移除(持久边界镜像——每项都以 session log 为权威):`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`。 +已移除(持久边界镜像——每项都以会话日志为权威):`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`。 保留——不是持久边界镜像,因此不在本决策范围内: @@ -39,7 +39,7 @@ Status: implemented ## 曾考虑的替代方案 -- **将 `agent/steering` 一并移除**——原始提案的形状;作为范围蔓延被排除:它镜像持久的 `steering/message` 控制记录,而非边界,后来由[自己的决策](2026-07-04-remove-agent-steering-mirror.md)移除(`agent/stream-chunk` 也由 [stream chunk 镜像 Agent Note](2026-07-02-remove-stream-chunk-mirror.md) 移除)。 +- **将 `agent/steering` 一并移除**——原始提案的形状;作为范围蔓延被排除:它镜像持久的 `steering/message` 控制记录,而非边界,后来由[自己的决策](2026-07-04-remove-agent-steering-mirror.md)移除(`agent/stream-chunk` 也由[流分片镜像 Agent Note](2026-07-02-remove-stream-chunk-mirror.md)移除)。 - **为 stdio UI 保留轮次镜像**——[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的原始立场;在此否决,因为 `dsh-ui-stdio` 是可随时丢弃的测试 REPL,而非承载关键约束的消费方,并且它改为根据 `session/event` 加自己的实时目标对象渲染边界。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml index 02addf9d6a..29467c4b98 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-unify-agent-and-session-id.md: c55152f4f13fe0acb530503e84f465799007cff7 -2026-06-20-unify-agent-and-session-id.zh.md: 943d424ace4544d4c3a6787435b0bf95d99d7426 +2026-06-20-unify-agent-and-session-id.zh.md: ac6f7e09b20b2b6e17ca0e9f6ebb088abaf5a996 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md index 943d424ace..ac6f7e09b2 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 统一 agent id 与 session id +# Agent Note: 统一 agent id 与会话 id Status: implemented @@ -6,17 +6,17 @@ Status: implemented ## 问题 -一个实时 agent(智能体)/session 对需要使用同一 identity 完成注册表路由、事件溯源和持久化。让 factory 接受相互独立的 `agentId` 和 `sessionId` 输入,会允许任何生产路径都无法使用的配对,同时迫使每个消费方为同一生命周期在两个名称之间选择或转换。 +一个实时 agent(智能体)/会话对需要使用同一 identity 完成注册表路由、事件溯源和持久化。让 factory 接受相互独立的 `agentId` 和 `sessionId` 输入,会允许任何生产路径都无法使用的配对,同时迫使每个消费方为同一生命周期在两个名称之间选择或转换。 -ACP(Agent Client Protocol)对两种 identity 使用相同值。Stdio 和 hook 也在 session 事件流上工作,并且直接需要对应的实时 agent;没有生产路径会把一个实时 agent 对象重新附着到多个 session,或通过多个 agent id 驱动一个 session。 +ACP(Agent Client Protocol)对两种 identity 使用相同值。Stdio 和钩子也在会话事件流上工作,并且直接需要对应的实时 agent;没有生产路径会把一个实时 agent 对象重新附着到多个会话,或通过多个 agent id 驱动一个会话。 -[agent 范围运行时](../architecture/2026-07-12-agent-scope-runtime-design.md)使用同一个 `AgentCreationTransaction` 执行创建和恢复,agent/session 条目共享相同的最终条目冲突规则。第二个 identity 并不代表单独的存活性、回滚或静止状态;它只会围绕同一事务增加 API 与转换状态。 +[agent 范围运行时](../architecture/2026-07-12-agent-scope-runtime-design.md)使用同一个 `AgentCreationTransaction` 执行创建和恢复,agent/会话条目共享相同的最终条目冲突规则。第二个 identity 并不代表单独的存活性、回滚或静止状态;它只会围绕同一事务增加 API 与转换状态。 -Session identity 同样只有一个归属,即 `Session.header.id`;`Session.id` 是派生访问器,而非需要重复验证的独立状态。 +会话 identity 同样只有一个归属,即 `Session.header.id`;`Session.id` 是派生访问器,而非需要重复验证的独立状态。 ## 决策 -agent 的注册表 id 等于其 session id。`CreateAgentOptions` 接受一个 `sessionId`,同时用于两个最终注册表条目;恢复时以 `resumeSessionId` 注册 agent;进程内 subagent 创建使用子 session id;`Session.id` 则派生自 `header.id`。远程 ACP 运行没有本地 agent/session 对:它保留一个由父项铸造的生命周期 id,而子服务器线协议内的 session id 仅用于 ACP 调用。现有创建事务、最终条目冲突检查和精确条目分离语义保持不变;唯一职责是在本地 id 之间转换的 map 与字段已经消失。 +agent 的注册表 id 等于其会话 id。`CreateAgentOptions` 接受一个 `sessionId`,同时用于两个最终注册表条目;恢复时以 `resumeSessionId` 注册 agent;进程内 subagent 创建使用子会话 id;`Session.id` 则派生自 `header.id`。远程 ACP 运行没有本地 agent/会话对:它保留一个由父项铸造的生命周期 id,而子服务器线协议内的会话 id 仅用于 ACP 调用。现有创建事务、最终条目冲突检查和精确条目分离语义保持不变;唯一职责是在本地 id 之间转换的 map 与字段已经消失。 配置驱动路径保留 `agents[].id` 作为稳定配置标签,而非实时路由 identity。普通的全新启动会铸造组合 id `${label}-session-${randomUUID()}`,使持久重启不会冲突。耦合应用可以预先铸造并传入精确的 `sessionId`:首次使用时创建它,而当持久化服务已经存在时,AgentLoop 重新挂载会在同一 identity 下恢复已物化历史。`resumeSessionId` 则要求已有的持久化 identity。两个精确 id 输入互斥。Stdio 使用“恢复或创建”形式,使配置创建的 agent 和 UI 在循环重载之间共享一个不透明 identity,而不是根据前缀猜测。日志可以使用稳定标签,而所有实时与持久查找都使用同一个 `SessionId`。 @@ -30,11 +30,11 @@ agent 的注册表 id 等于其 session id。`CreateAgentOptions` 接受一个 ` - Agent 创建/恢复和 subagent 创建只携带一个 identity,`Session` 也只在一个位置存储它。 - 创建事务继续覆盖最终条目冲突、精确条目分离、回滚和静止状态,无需 identity 特有的生命周期状态。 -- ACP、stdio、hook、bash 归属、持久化和 lineage 直接使用共享 `SessionId`。ACP subagent 后端在父命名空间中铸造其生命周期 id,因为子服务器返回的 session id 仅在服务器本地有效;ACP bridge 根据正向 session map 验证精确的 `Agent` 归属;JSON-RPC 只转发生命周期事件中由服务快照保存的 `local` 标记为 true 的事件,从带范围的事件 carrier 取得委托父项,并且不保留子 identity 或 lineage cache。 +- ACP、stdio、钩子、bash 归属、持久化和 lineage 直接使用共享 `SessionId`。ACP subagent 后端在父命名空间中铸造其生命周期 id,因为子服务器返回的会话 id 仅在服务器本地有效;ACP bridge 根据正向会话 map 验证精确的 `Agent` 归属;JSON-RPC 只转发生命周期事件中由服务快照保存的 `local` 标记为 true 的事件,从带范围的事件 carrier 取得委托父项,并且不保留子 identity 或 lineage cache。 - 配置驱动的恢复还是创建策略是显式的,并在持久化重启场景下得到覆盖。 - 生产监听器搜索确认保留 `agent/created`/`agent/disposed` 及其发布语义。 - 类型检查、覆盖率、快照、doc-sync、module-graph 校验、构建与 hygiene 全部通过。 ## 后果 -这排除了潜在的多 session actor 和 session 交接设计,并使由客户端选择、已持久化的 session identity 成为注册表 identity。如果独立路由 identity 成为真实需求,就需要显式的生命周期设计,而不是由调用方提供一对不受约束的值。 +这排除了潜在的多会话 actor 和会话交接设计,并使由客户端选择、已持久化的会话 identity 成为注册表 identity。如果独立路由 identity 成为真实需求,就需要显式的生命周期设计,而不是由调用方提供一对不受约束的值。 diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml index 08448a9541..6e428fa348 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-26-fsspec-style-fs-seam.md: d496f273e2635624e0ab8e70e06c8729563c5466 -2026-06-26-fsspec-style-fs-seam.zh.md: 54d08b28a7004b47f20f4947a39264819260d9bf +2026-06-26-fsspec-style-fs-seam.zh.md: 18e4be5177f593253dc100a864b6c741904a90b2 diff --git a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md index 54d08b28a7..18e4be5177 100644 --- a/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md @@ -30,7 +30,7 @@ provider dsh-fs-local local implementation of ctx.fs `dsh-tool-fs` 保持相同的面向模型的 `read`/`write`/`edit` schema。它是执行器:注入 `fs`(不是策略服务)并直接访问 `ctx.fs`,拥有读取窗口化逻辑,并分发 `fs/*` 事件以便 `dsh-fs-policy` 进行门控和记录。 -本 Agent Note 决定了四层拆分、provider 契约和新鲜度策略。随后,[事件门禁 Agent Note](../architecture/2026-06-26-file-context-as-event-gate.md) 细化了工具↔策略耦合:`dsh-fs-policy` 是通过 `fs/*` 事件参与的门禁插件,而非 `ctx.fileContext` 方法服务,因此工具不会在方法层与其耦合;读取窗口和 fs I/O 位于 `dsh-tool-fs`。本文描述已经落地的事件门禁形状;provider 的版本守卫可选(省略即无条件裸 provider)。 +本 Agent Note 决定了四层拆分、提供方契约和新鲜度策略。随后,[事件门禁 Agent Note](../architecture/2026-06-26-file-context-as-event-gate.md) 细化了工具↔策略耦合:`dsh-fs-policy` 是通过 `fs/*` 事件参与的门禁插件,而非 `ctx.fileContext` 方法服务,因此工具不会在方法层与其耦合;读取窗口和 fs I/O 位于 `dsh-tool-fs`。本文描述已经落地的事件门禁形状;提供方的版本守卫可选(省略即无条件裸提供方)。 ## 提供方契约 @@ -65,11 +65,11 @@ type FsWriteIntent = 这是一个*文本存储* seam,刻意比字节级 fsspec(`cat`/`open` 返回原始字节)高半个层次。UTF-8 解码、二进制/NUL 拒绝、受保护的全文件写入和受保护的字面文本编辑都在提供方内完成,因此策略层从不接触原始字节、不重新实现跨分片解码、也不将陈旧检查与变更临界区分离。面向模型的概念仍然不下沉到提供方:行窗口、带行号的行、渲染的页脚、观测状态存储都不会泄漏下去。 -从 `dsh-fs` 删除:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody` 和 observed-state `WeakMap`。`applyEdit` 由更窄的 provider 原语 `editText` 取代,其契约是带版本守卫的字面文本变更,而非策略层读取授权。`FS_PARTIAL_OBSERVATION` code 也从 `FsErrorCode` 分类中移除:新鲜度授权没有部分/完整之分,因此没有任何路径会抛出它。`FsTargetKey` 和 `FsVersion` 按现有[品牌化 id Agent Note](../architecture/2026-06-20-branded-ids.md) 成为品牌化不透明 id。 +从 `dsh-fs` 删除:`readPage`、`FsExpectation`、`FsView`、`FsStateSource`、`FsReadRequest`、`FsTextLine`、行/窗口常量、`formatReadBody` 和 observed-state `WeakMap`。`applyEdit` 由更窄的提供方原语 `editText` 取代,其契约是带版本守卫的字面文本变更,而非策略层读取授权。`FS_PARTIAL_OBSERVATION` code 也从 `FsErrorCode` 分类中移除:新鲜度授权没有部分/完整之分,因此没有任何路径会抛出它。`FsTargetKey` 和 `FsVersion` 按现有[品牌化 id Agent Note](../architecture/2026-06-20-branded-ids.md) 成为品牌化不透明 id。 ## 策略契约 -`@deepseek-ai/dsh-fs-policy` 是插件,而非服务:它不注册任何 `ctx.*` 键,也不注入任何内容。它拥有不应位于 `FileSystem` provider 基类上的写入/编辑新鲜度策略和 observed state(否则 sandbox/远程后端会继承不该由其承载的面向模型观察策略)。它通过 executor 分派的 `fs/*` 事件门禁贡献该策略。(本 Agent Note 最初提议带有 `read`/`write`/`edit` 方法的具体 `ctx.fileContext` 服务;[事件门禁 Agent Note](../architecture/2026-06-26-file-context-as-event-gate.md) 将其细化为本文所述插件,使工具永远不会在方法层与策略耦合。) +`@deepseek-ai/dsh-fs-policy` 是插件,而非服务:它不注册任何 `ctx.*` 键,也不注入任何内容。它拥有不应位于 `FileSystem` 提供方基类上的写入/编辑新鲜度策略和 observed state(否则沙箱/远程后端会继承不该由其承载的面向模型观察策略)。它通过执行器分派的 `fs/*` 事件门禁贡献该策略。(本 Agent Note 最初提议带有 `read`/`write`/`edit` 方法的具体 `ctx.fileContext` 服务;[事件门禁 Agent Note](../architecture/2026-06-26-file-context-as-event-gate.md) 将其细化为本文所述插件,使工具永远不会在方法层与策略耦合。) 观测状态以 `WeakMap<owner, Map<targetKey, FsVersion>>` 的形式存放于此。当且仅当 owner 读取、写入或编辑过该目标时,条目才存在(每次成功都会发出 `fs/observed`),因此条目的存在*本身就是*先前观测的记录——没有单独的 `hasRead` 标志。owner 从不透明的事件 actor(`{ agent?: { session? } }`)结构化派生,该形状定义在 `dsh-fs-policy` 中而非 `dsh-fs` 中。 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 8606c3ccfe..1f3571ea76 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-remove-stream-chunk-mirror.md: 5dd816940a4b2c2b63980e01f1bd4e0aac53a3e2 -2026-07-02-remove-stream-chunk-mirror.zh.md: 7d0559ec18b52fe37340eea9e772ab92bc8871ed +2026-07-02-remove-stream-chunk-mirror.zh.md: cb197205e0954d8197c2c4861d7f113e0461678d diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index 7d0559ec18..cb197205e0 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -19,22 +19,22 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror 实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方直接丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 -这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复相同:消费方面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把 chunk 流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将 chunk 流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 +这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复相同:消费方面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把分片流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将分片流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 -推迟所依赖的前提已经明确:chunk 持久化是权威的,且将保留。停止持久化 chunk、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 +推迟所依赖的前提已经明确:分片持久化是权威的,且将保留。停止持久化分片、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 ## 决策 -从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化与回放已经使用的正是同一个序列。`session/event` 是唯一的实时 transcript(文本记录)流(assistant chunk、轮次/步骤边界、工具活动、todo)。 +从 agent 事件分类体系中移除 `agent/stream-chunk`。token 流通过 `session/event` 以 `assistant/chunk` 的形式读取——持久化与回放已经使用的正是同一个序列。`session/event` 是唯一的实时 transcript(文本记录)流(assistant 分片、轮次/步骤边界、工具活动、todo)。 -**消费方。** 唯一重要的生产消费方——ACP(Agent Client Protocol)桥接(`dsh-acp`,面向编辑器的真实流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其 chunk 渲染被折叠进该监听器作为 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立监听器(`agent/stream-chunk` 和 `session/event`)之间共享,chunk 与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 +**消费方。** 唯一重要的生产消费方——ACP(Agent Client Protocol)桥接(`dsh-acp`,面向编辑器的真实流式输出接口)——已经从 `session/event` 渲染 `assistant/chunk`,从未使用 `agent/stream-chunk`,因此不受影响。stdio UI(`dsh-ui-stdio`,一个一次性的测试 REPL)是唯一的实时消费方;它在边界迁移时已经有了 `session/event` 监听器,因此其分片渲染被折叠进该监听器作为 `assistant/chunk` 分支。合并为一个监听器还消除了一个潜在隐患:`inReasoning` dim-SGR 标志此前在两个独立监听器(`agent/stream-chunk` 和 `session/event`)之间共享,分片与边界在该标志上竞争时没有确定的顺序;单一监听器按追加顺序处理,使交错变为确定性的。 ## 范围 移除:`agent/stream-chunk`。 未触及: -- `assistant/chunk`(持久 session 事件)——权威 token 流,原样保留。本 Agent Note 移除的是实时镜像,而非持久化(移除持久化的提案已单独遭到拒绝——见上文)。 +- `assistant/chunk`(持久会话事件)——权威 token 流,原样保留。本 Agent Note 移除的是实时镜像,而非持久化(移除持久化的提案已单独遭到拒绝——见上文)。 - `agent/steering`——本决策未触及(它是控制信号,不是 token 流)。其持久孪生事件是 `steering/message`,镜像发射由其自身的后续 Agent Note 移除:[移除 `agent/steering` 镜像发射](2026-07-04-remove-agent-steering-mirror.md)。 - `agent/status`、`agent/error`、`agent/created`/`agent/disposed`、`agent/queued`、`agent/session-start`——生命周期/控制事件,不是 transcript 数据,也没有持久副本。 @@ -44,4 +44,4 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror ## 后果 -插件不能再从 `Agent` 优先事件观察 token 增量。它需要订阅 `session/event`、过滤 `assistant/chunk`,并在需要时通过 `ctx.agents.get(session.id)` 直接查找对应的实时 handle。没有生产消费方需要在 chunk 时刻取得实时 `Agent`;这与移除边界镜像所作的取舍相同,均可接受。 +插件不能再从 `Agent` 优先事件观察 token 增量。它需要订阅 `session/event`、过滤 `assistant/chunk`,并在需要时通过 `ctx.agents.get(session.id)` 直接查找对应的实时 handle。没有生产消费方需要在分片时刻取得实时 `Agent`;这与移除边界镜像所作的取舍相同,均可接受。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml index cf67ddfcd5..464ffc54d4 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-drop-image-content-block.md: 566803ab5b9f213b7dc87fcf779ed7a963d988fd -2026-07-04-drop-image-content-block.zh.md: 27282bbf8768cafaf3fff696559b81756af5a45e +2026-07-04-drop-image-content-block.zh.md: 667ddbc1b7bce2a276fe7528f46d3e43916bad83 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md index 27282bbf87..667ddbc1b7 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其丢弃:deepseek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP(Agent Client Protocol)编解码器既不宣告 image prompt 能力、也不向外转发 image 块,并且会拒绝入站的 image prompt 内容;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 +`ImageBlock`(`packages/llm/llm/src/types.ts`)没有任何生产环境的生产者,而每条路径上的每个消费方都将其丢弃:deepseek 适配器的序列化器跳过 image 块(这是文档中注明的 MVP 限制);pi-ai 转换器因无法表示而跳过;ACP(Agent Client Protocol)编解码器既不宣告图像提示词能力、也不向外转发 image 块,并且会拒绝入站的图像提示词内容;压缩(compaction)估算器对其收取一个固定 token 常量并渲染为 `[image]`。此时构造的 `ImageBlock` 会在协议格式(wire format)上静默消失——词汇宣告了一种没有任何路径兑现的能力,这正是 AGENTS.md 防御性模式所警告的静默数据丢失形态。唯一的构造调用出现在测试中,用于覆盖 skip/drop/estimate 分支。 ## 决策 -移除 `ImageBlock`、其 map 条目,以及适配器、ACP 渲染和压缩中的 image 专用分支。在同一个变更中更新所属的词汇文档与生成的引用。未知扩展块仍然覆盖默认分支,ACP 继续独立于 harness 词汇拒绝入站的 image prompt 内容。 +移除 `ImageBlock`、其 map 条目,以及适配器、ACP 渲染和压缩中的 image 专用分支。在同一个变更中更新所属的词汇文档与生成的引用。未知扩展块仍然覆盖默认分支,ACP 继续独立于 harness 词汇拒绝入站的图像提示词内容。 ## 曾考虑的替代方案 @@ -22,7 +22,7 @@ Status: implemented ## 验证 -除 Agent Note(agent 决策记录)之外,没有任何地方构造 harness `ImageBlock`。ACP 独立的入站图像拒绝路径仍有测试;adapter、codec 和压缩的默认分支则使用插件定义的块类型覆盖。 +除 Agent Note(agent 决策记录)之外,没有任何地方构造 harness `ImageBlock`。ACP 独立的入站图像拒绝路径仍有测试;适配器、codec 和压缩的默认分支则使用插件定义的块类型覆盖。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml index f55ba68808..4d018139c5 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-drop-inert-request-knobs.md: 06fa6c1c539f9ff0cfabf76bc41c53800bd46c8c -2026-07-04-drop-inert-request-knobs.zh.md: 69a8b0ca498f9fe11df5eb1f3207d88f66a7b70b +2026-07-04-drop-inert-request-knobs.zh.md: 42aadde2b279a453fac9444060b8ac34bf9e3c8b diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md index 69a8b0ca49..42aadde2b2 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md @@ -15,20 +15,20 @@ Status: implemented ## 决策 -- 从 `GenerateOptions` 中移除 `prefill`,同时移除两个 adapter 的 UNSUPPORTED 守卫、固定抛错行为的测试、[core.md](../../../../docs/core-data-structures/core.md) 中的粘贴行,以及记录该拒绝行为的 adapter README 表格行。cookbook 中的 UNSUPPORTED 指导([adding-an-llm-adapter.md](../../../../docs/cookbook/adding-an-llm-adapter.md))改为通用表述规则——provider 无法遵守的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 Agent Note(agent 决策记录)](../architecture/2026-06-11-content-block-vocabulary.md)的后果按照 [implemented/AGENTS.md](../AGENTS.md),将 prefill 记录为由生产者门控,而不是已有归属。 -- 从 `ToolSchema`、`DefineToolOptions`、`defineTool`、`schemas()` 允许列表、deepseek 序列化分支及其 wire-type 字段,以及 tool-catalog 渲染器的 `Strict:` 行中移除 `strict`。pi-ai 的 payload 修补逻辑简化为对 pi-ai 自身逐工具 strict 默认值的无条件清除(pi-ai 在每个序列化的工具上打 `strict: false`;手写的孪生适配器不发送此字段,因此清除逻辑为保持协议格式对等而保留,由其序列化器测试固定)。setter 测试和 core.md 粘贴行已移除;`GenerateOptions` 与 `ToolSchema` 在 `scripts/type-equiv.manifest.json` 中保留各自的行,因为两个类型只是少了一个字段,本身仍然存在。 +- 从 `GenerateOptions` 中移除 `prefill`,同时移除两个适配器的 UNSUPPORTED 守卫、固定抛错行为的测试、[core.md](../../../../docs/core-data-structures/core.md) 中的粘贴行,以及记录该拒绝行为的适配器 README 表格行。实操手册中的 UNSUPPORTED 指导([adding-an-llm-adapter.md](../../../../docs/cookbook/adding-an-llm-adapter.md))改为通用表述规则——提供方无法遵守的 `GenerateOptions` 字段应抛出 `LlmError(..., 'UNSUPPORTED')`——而不再以 prefill 为例。[内容块词汇 Agent Note(agent 决策记录)](../architecture/2026-06-11-content-block-vocabulary.md)的后果按照 [implemented/AGENTS.md](../AGENTS.md),将 prefill 记录为由生产者门控,而不是已有归属。 +- 从 `ToolSchema`、`DefineToolOptions`、`defineTool`、`schemas()` 允许列表、deepseek 序列化分支及其 wire-type 字段,以及工具目录渲染器的 `Strict:` 行中移除 `strict`。pi-ai 的 payload 修补逻辑简化为对 pi-ai 自身逐工具 strict 默认值的无条件清除(pi-ai 在每个序列化的工具上打 `strict: false`;手写的孪生适配器不发送此字段,因此清除逻辑为保持协议格式对等而保留,由其序列化器测试固定)。setter 测试和 core.md 粘贴行已移除;`GenerateOptions` 与 `ToolSchema` 在 `scripts/type-equiv.manifest.json` 中保留各自的行,因为两个类型只是少了一个字段,本身仍然存在。 -本 Agent Note 刻意不触及 `temperature`、`stop` 或 `maxTokens`:两个 adapter 都会端到端遵守它们,而且它们自然是 `agent/request` 上修改请求的 hook 插件首批目标。 +本 Agent Note 刻意不触及 `temperature`、`stop` 或 `maxTokens`:两个适配器都会端到端遵守它们,而且它们自然是 `agent/request` 上修改请求的钩子插件首批目标。 ## 曾考虑的替代方案 ### 为什么不保留? -「显式的 UNSUPPORTED throw 是诚实的契约行为」——但一个在两个孪生适配器中唯一的实现就是拒绝的旋钮,什么也没承诺;删除它反而升级了失败模式:意外的 setter 变成编译错误而非运行时 throw。「Strict schema 遵循是官方文档记载的 provider 功能,且管道完整」——但一个旋钮在有已发布的工具设置它并且有端点兑现它之前,不构成产品表面;今天两者都不成立。它们各自随首个真实 producer 回归:`prefill` 随实现了 chat-prefix completion 的适配器(以及对不支持该功能的适配器的明确策略)一起回来;`strict` 随需要它的工具和 beta 端点方案一起回来。 +「显式的 UNSUPPORTED throw 是诚实的契约行为」——但一个在两个孪生适配器中唯一的实现就是拒绝的旋钮,什么也没承诺;删除它反而升级了失败模式:意外的 setter 变成编译错误而非运行时 throw。「Strict schema 遵循是官方文档记载的提供方功能,且管道完整」——但一个旋钮在有已发布的工具设置它并且有端点兑现它之前,不构成产品表面;今天两者都不成立。它们各自随首个真实 producer 回归:`prefill` 随实现了 chat-prefix completion 的适配器(以及对不支持该功能的适配器的明确策略)一起回来;`strict` 随需要它的工具和 beta 端点方案一起回来。 ## 验证 -`rg prefill` 只返回 Agent Note 记录(本文及[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)中由生产者门控的后果);限定在工具 schema 范围内的 `rg strict` 只返回本 Agent Note、保留下来的 pi-ai 清理逻辑,以及 `strictEqual` 等无关正文。两个 adapter 的契约测试都能在没有守卫的情况下通过,pi-ai 修正仍会清理库的 strict 默认值——其 serializer 测试固定了线协议一致性。 +`rg prefill` 只返回 Agent Note 记录(本文及[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)中由生产者门控的后果);限定在工具 schema 范围内的 `rg strict` 只返回本 Agent Note、保留下来的 pi-ai 清理逻辑,以及 `strictEqual` 等无关正文。两个适配器的契约测试都能在没有守卫的情况下通过,pi-ai 修正仍会清理库的 strict 默认值——其 serializer 测试固定了线协议一致性。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml index 3aa46c2fc0..2845b37a05 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-drop-unconsumed-web-observation-surface.md: 5b1cb1307c63ef7c298200ee1655119026b9ebf5 -2026-07-04-drop-unconsumed-web-observation-surface.zh.md: ea97b59332d2c78810d0a6c43f2b7d99d96ccc5f +2026-07-04-drop-unconsumed-web-observation-surface.zh.md: c4351c7e66d00f47e1bf9ed117c5f7b043045808 diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md index ea97b59332..c4351c7e66 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md @@ -8,22 +8,22 @@ Status: implemented `WebService` 暴露了一组没有任何生产代码观测的观测接口: -- **`web/providers-change`**(`packages/web/web/src/index.ts`)在每次 provider 注册和 dispose(资源释放)时声明并发出,且每个注册 effect 的回滚 yield 被刻意排在 emit 之前,唯一目的是让抛出异常的 change listener 能回退注册。在该包自身的两个单元测试之外没有任何 listener(其中一个测试的存在仅仅是为了固定那个回滚顺序)。 +- **`web/providers-change`**(`packages/web/web/src/index.ts`)在每次提供方注册和 dispose(资源释放)时声明并发出,且每个注册 effect 的回滚 yield 被刻意排在 emit 之前,唯一目的是让抛出异常的 change listener 能回退注册。在该包自身的两个单元测试之外没有任何 listener(其中一个测试的存在仅仅是为了固定那个回滚顺序)。 - **`searchStatus()` / `fetchStatus()` 与 `WebCapabilityStatus` 联合类型**(同一包)没有生产调用方:`dsh-tool-web` 直接通过 `ctx.web.search()`/`fetch()` 执行,并把不可用性呈现为 seam 在执行时抛出的结构化 `WebError` code(`packages/web/tool-web/src/search.ts`、`packages/web/tool-web/src/fetch.ts`);唯一的 status 调用方是 web 包自己的测试。`packages/web/tool-web/README.md` 和 [architecture.md](../../../../docs/architecture.md) 中的正文声称工具“只读取聚合的 `searchStatus()`/`fetchStatus()`”——这种漂移之所以存续,只是因为没有机制对照调用位置检查正文。 -seam 自身的设计使这两个接口天然没有消费方:工具注册跟随产品 ENABLEMENT 而非 provider 可用性(`packages/web/tool-web/src/index.ts`),provider 选择在执行时解析且从不缓存——因此没有需要失效的缓存、没有需要重算的注册集合、也没有调用方需要一个有别于「执行并路由结构化错误」的可用性探测。HMR(热模块替换)清理由 effect disposer 自身承载。 +seam 自身的设计使这两个接口天然没有消费方:工具注册跟随产品 ENABLEMENT 而非提供方可用性(`packages/web/tool-web/src/index.ts`),提供方选择在执行时解析且从不缓存——因此没有需要失效的缓存、没有需要重算的注册集合、也没有调用方需要一个有别于「执行并路由结构化错误」的可用性探测。HMR(热模块替换)清理由 effect disposer 自身承载。 -这与[删除无人消费的 `llm/adapter-change` 事件](2026-06-20-drop-unconsumed-llm-adapter-change-event.md)相呼应;后者从 `LlmService` 移除了相同的通知形状、相同的 emit 前回滚机制和相同的监听器抛错测试。该 Agent Note(agent 决策记录)的保留/删除标准是:为可能面向用户的工具列表消费方保留 `tools/change`,删除启动时后端注册表信号。按这一标准,web provider 注册表明确属于删除一侧;status 方法则是把同一判断应用于拉取表面,而非推送表面。 +这与[删除无人消费的 `llm/adapter-change` 事件](2026-06-20-drop-unconsumed-llm-adapter-change-event.md)相呼应;后者从 `LlmService` 移除了相同的通知形状、相同的 emit 前回滚机制和相同的监听器抛错测试。该 Agent Note(agent 决策记录)的保留/删除标准是:为可能面向用户的工具列表消费方保留 `tools/change`,删除启动时后端注册表信号。按这一标准,web 提供方注册表明确属于删除一侧;status 方法则是把同一判断应用于拉取表面,而非推送表面。 ## 决策 -移除注册表变更事件、聚合 status 方法与类型,以及它们的专属测试。provider 私有的 status 保留用于执行时选择。面向调用方的覆盖率现在断言成功执行或结构化的选择错误,web 文档描述该按需调用契约。 +移除注册表变更事件、聚合 status 方法与类型,以及它们的专属测试。提供方私有的 status 保留用于执行时选择。面向调用方的覆盖率现在断言成功执行或结构化的选择错误,web 文档描述该按需调用契约。 ## 曾考虑的替代方案 ### 为什么不保留? -web seam Agent Note 刻意规定了两者——事件作为最小 HMR 可见性信号,status 方法作为工具的聚合诊断——未来也可以设想 provider 状态面板。但同一 Agent Note 的其他选择让它们失去了生存条件:调用时派生选择和基于启用状态的注册,使任何消费方都不可能需要其中任一项;已发布工具展示了真实模式(执行并路由结构化错误);发生漂移的 README 句子则表明承诺中的消费方从未出现。按照 AGENTS.md 所述“Agent Note 是提案,而非绝对真理”,代码后来证明提案中的这些部分超出了需要;未来的观察者应根据真实消费方的形状,重新引入它实际消费的最小信号或查询。 +web seam Agent Note 刻意规定了两者——事件作为最小 HMR 可见性信号,status 方法作为工具的聚合诊断——未来也可以设想提供方状态面板。但同一 Agent Note 的其他选择让它们失去了生存条件:调用时派生选择和基于启用状态的注册,使任何消费方都不可能需要其中任一项;已发布工具展示了真实模式(执行并路由结构化错误);发生漂移的 README 句子则表明承诺中的消费方从未出现。按照 AGENTS.md 所述“Agent Note 是提案,而非绝对真理”,代码后来证明提案中的这些部分超出了需要;未来的观察者应根据真实消费方的形状,重新引入它实际消费的最小信号或查询。 ## 验证 @@ -31,4 +31,4 @@ web seam Agent Note 刻意规定了两者——事件作为最小 HMR 可见性 ## 后果 -未来若有 provider 选择器 UI 或诊断面板需要变更通知或 status 查询,它将重新添加自身所消费的最小接口;相同的判断及其反转条件已记录在 LLM(大语言模型)先例中。 +未来若有提供方选择器 UI 或诊断面板需要变更通知或 status 查询,它将重新添加自身所消费的最小接口;相同的判断及其反转条件已记录在 LLM(大语言模型)先例中。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml index f11e99d542..c343b6ff92 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-producerless-vocabulary-variants.md: 34492e6906cd2d795f880310b1bcd120e3953fcf -2026-07-04-prune-producerless-vocabulary-variants.zh.md: 3cbf89e89828244719bcdf4c7aebeab4eb028ded +2026-07-04-prune-producerless-vocabulary-variants.zh.md: a68a8b04fedefed8a2ab92f08baf5e8b3ea90222 diff --git a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md index 3cbf89e898..a68a8b04fe 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md @@ -8,9 +8,9 @@ Status: implemented 可合并扩展的词汇映射表设计上通过声明合并来增长,代码库已在 `TurnEndReasonMap`(`packages/core/session/src/types.ts`)上明确了准入策略:像 `refusal` 这样的变体「在适配器或循环首次发出它之前,有意不纳入」。三个已声明的词汇项违反了该策略——每个都既无生产者也无消费方,其中两个甚至没有测试: -- **`TextBlock`/`ToolResultBlock` 上的 `CacheHint` 及其 `cache?: CacheHint` 块字段**(`packages/llm/llm/src/types.ts`;图像块曾有第三个此类字段,已随图像块一同移除——参见[删除图像 Agent Note(agent 决策记录)](2026-07-04-drop-image-content-block.md))。任何地方都没有构造带 `cache:` 的块——src、测试和文档粘贴均为空——两个 adapter 也都不读取 `.cache`:DeepSeek prompt caching 是自动的,因此 adapter 会从响应中映射出 `prompt_cache_hit_tokens`,却从不向请求中发送 hint。这是没有任何 provider 能够遵守的 Anthropic 风格 `cache_control` 表面。 -- **`MessageSourceMap.agent`**(`{ kind: 'agent'; agentId: string }`,同一文件)。零个构造点,包括测试在内。它预期的生产者在实现时并未使用它:subagent 后端将父级的 prompt 发送给子级时不带 `source`,因此记录为 `{ kind: 'user' }`,通用信封渲染器在插值 `source.kind` 时也从未对其做路由。 -- **`TurnTriggerMap.continuation`**(`packages/core/session/src/types.ts`)。agent loop(智能体循环)在结构上不可能发出它——continuation 发生在一个轮次*内部*作为后续步骤,而非作为新轮次——循环只构造 `message` 和 `injection` 触发器。唯一的写入者是一个手工构建的测试 fixture(测试前置数据),它只需要一个任意的非 message 触发器(`packages/support/llm-replay/tests/llm-replay.spec.ts`),`injection` 触发器同样满足需求;唯一的生产环境触发器读取方 ACP(Agent Client Protocol)桥接层只过滤 `kind === 'message'`。 +- **`TextBlock`/`ToolResultBlock` 上的 `CacheHint` 及其 `cache?: CacheHint` 块字段**(`packages/llm/llm/src/types.ts`;图像块曾有第三个此类字段,已随图像块一同移除——参见[删除图像 Agent Note(agent 决策记录)](2026-07-04-drop-image-content-block.md))。任何地方都没有构造带 `cache:` 的块——src、测试和文档粘贴均为空——两个适配器也都不读取 `.cache`:DeepSeek 的提示词缓存是自动的,因此适配器会从响应中映射出 `prompt_cache_hit_tokens`,却从不向请求中发送 hint。这是没有任何提供方能够遵守的 Anthropic 风格 `cache_control` 表面。 +- **`MessageSourceMap.agent`**(`{ kind: 'agent'; agentId: string }`,同一文件)。零个构造点,包括测试在内。它预期的生产者在实现时并未使用它:subagent 后端将父级的提示词发送给子级时不带 `source`,因此记录为 `{ kind: 'user' }`,通用信封渲染器在插值 `source.kind` 时也从未对其做路由。 +- **`TurnTriggerMap.continuation`**(`packages/core/session/src/types.ts`)。agent loop(智能体循环)在结构上不可能发出它——continuation 发生在一个轮次*内部*作为后续步骤,而非作为新轮次——循环只构造 `message` 和 `injection` 触发器。唯一的写入者是一个手工构建的测试 fixture(测试前置数据),它只需要一个任意的非消息触发器(`packages/support/llm-replay/tests/llm-replay.spec.ts`),`injection` 触发器同样满足需求;唯一的生产环境触发器读取方 ACP(Agent Client Protocol)桥接层只过滤 `kind === 'message'`。 ## 决策 @@ -22,7 +22,7 @@ Status: implemented ### 为什么不保留它们? -[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)曾把“cache hint……有了归属”列为设计后果,预留槽位也确实能表明意图。但空槽位是每个实现和消费方都必须考虑的契约表面(我的 adapter 是否必须遵守 `cache`?我的 renderer 是否必须路由 `agent` 来源?),而相邻 map 自身的 JSDoc 已经拒绝“无 emitter 先预留”——`refusal` 和 `max_turn_requests` 被点名为*首次有内容发出它们时*再添加的变体,而不是提前声明。让已经声明但无用的变体遵守同一标准,才能使词汇真正有意义:只要它位于 map 中,就必须有内容生产它。 +[内容块词汇 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)曾把“cache hint……有了归属”列为设计后果,预留槽位也确实能表明意图。但空槽位是每个实现和消费方都必须考虑的契约表面(我的适配器是否必须遵守 `cache`?我的 renderer 是否必须路由 `agent` 来源?),而相邻 map 自身的 JSDoc 已经拒绝“无 emitter 先预留”——`refusal` 和 `max_turn_requests` 被点名为*首次有内容发出它们时*再添加的变体,而不是提前声明。让已经声明但无用的变体遵守同一标准,才能使词汇真正有意义:只要它位于 map 中,就必须有内容生产它。 ## 验证 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -操作行为没有变化——原本就没有内容能够构造这些值。镜像事件移除([边界镜像 Agent Note](2026-06-20-remove-agent-boundary-mirror-events.md)、[stream chunk Agent Note](2026-07-02-remove-stream-chunk-mirror.md))只触及瞬态 `agent/*` 事件,从不触及持久词汇,因此不存在冲突。其他位置已经遵守准入策略:`rejected`、`prompt/blocked` 和 `hook/invoked`/`hook/result` 都有实时生产者——本 Agent Note 将同一门槛扩展到缺少生产者的三个变体。图像块自身的 `cache?` 字段归属[删除图像 Agent Note](2026-07-04-drop-image-content-block.md),后者将其与该块一同移除;本 Agent Note 覆盖剩余块类型上的两个字段。 +操作行为没有变化——原本就没有内容能够构造这些值。镜像事件移除([边界镜像 Agent Note](2026-06-20-remove-agent-boundary-mirror-events.md)、[流分片 Agent Note](2026-07-02-remove-stream-chunk-mirror.md))只触及瞬态 `agent/*` 事件,从不触及持久词汇,因此不存在冲突。其他位置已经遵守准入策略:`rejected`、`prompt/blocked` 和 `hook/invoked`/`hook/result` 都有实时生产者——本 Agent Note 将同一门槛扩展到缺少生产者的三个变体。图像块自身的 `cache?` 字段归属[删除图像 Agent Note](2026-07-04-drop-image-content-block.md),后者将其与该块一同移除;本 Agent Note 覆盖剩余块类型上的两个字段。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml index 00ce291031..8b6bb07da2 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-remove-agent-steering-mirror.md: 9f7cd5abe968ff216cbd7012163ea1c04dc00599 -2026-07-04-remove-agent-steering-mirror.zh.md: df4d8af4b552286068d5aa54f25e8e8dfb06df54 +2026-07-04-remove-agent-steering-mirror.zh.md: 63f575347d989f288b3129e0a53e5690b85bb4e8 diff --git a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md index df4d8af4b5..63f575347d 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md @@ -10,13 +10,13 @@ Status: implemented `agent/steering` 以相同的 payload 重复了紧接其前的持久事件 `steering/message`。`agent/queued` 仍保留为纯瞬态信号,因为它在持久化之前触发,覆盖了可能在进入日志前被取消的工作。 -Steering 承载真实生产流量——hook bridge 的轮次延续决策通过 `inbox.steer()` 注入其理由,最终成为由 hook 矩阵预期输出固定的持久 `steering/message` 事件——而这些消费方无一例外都观察持久事件。没有任何内容观察镜像。 +Steering 承载真实生产流量——钩子 bridge 的轮次延续决策通过 `inbox.steer()` 注入其理由,最终成为由钩子矩阵预期输出固定的持久 `steering/message` 事件——而这些消费方无一例外都观察持久事件。没有任何内容观察镜像。 ## 决策 `agent/steering` 已从 agent 事件分类中移除:包括 `packages/core/agent/src/types.ts` 中的声明(以及其中实时事件 JSDoc 列表对它的提及)、`drainSteering` 中的 emit(当时已无用的 `ctx` 参数也随之移除)、`packages/core/agent/README.md` 中的表格行,以及循环伪代码块(`packages/core/agent-loop/src/loop.ts` 模块文档和 [architecture.md](../../../../docs/architecture.md))中的 emit 行;Cordis 目录重新生成后不再包含它。唯一的回归测试改为在持久 `steering/message` 事件上固定来源保留行为——所固定的事实存在于日志上。 -三份已实现 Agent Note(agent 决策记录)曾说明保留该事件;按照 [implemented/AGENTS.md](../AGENTS.md),每份记录都已修改并指向本文作为移除记录:包括[边界 Agent Note](2026-06-20-remove-agent-boundary-mirror-events.md) 的保留列表条目、[stream chunk Agent Note](2026-07-02-remove-stream-chunk-mirror.md) 的范围条款,以及[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的瞬态 emit 枚举。 +三份已实现 Agent Note(agent 决策记录)曾说明保留该事件;按照 [implemented/AGENTS.md](../AGENTS.md),每份记录都已修改并指向本文作为移除记录:包括[边界 Agent Note](2026-06-20-remove-agent-boundary-mirror-events.md) 的保留列表条目、[流分片 Agent Note](2026-07-02-remove-stream-chunk-mirror.md) 的范围条款,以及[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md) 的瞬态 emit 枚举。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml index 834c3ccf6c..b2c35d276c 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-tighten-hook-protocol-contract.md: a1972ee8ef486982268ba8886b2413f3557061b4 -2026-07-04-tighten-hook-protocol-contract.zh.md: 891bab33dc2b7557b0024a7b2013894452c38605 +2026-07-04-tighten-hook-protocol-contract.zh.md: 4917a8f551672f51b0ebc27b1267c7e8e8eb2178 diff --git a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md index 891bab33dc..4917a8f551 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md @@ -8,8 +8,8 @@ Status: implemented `dsh-hook-protocol`/bridge 契约中有四部分没有遵守 [subagent observe/enrich Agent Note(agent 决策记录)](../feature/2026-06-30-subagent-observe-enrich.md)记下的准则——后者因缺少消费方而删除 `agentType` 生命周期字段,以下各项没有通过同一检验: -1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有生产者——bridge 会标记 `'claude'` 和 `'codex'`;所有位置中唯一构造 `'native'` 的是该库自己的单元测试。字段自身的 JSDoc 将 `dialect` 定义为“运行它的 bridge”,而 native 不是 bridge:[拦截 seam Agent Note](../feature/2026-06-30-interception-seams.md) 记载 native hook 不是一个包,并且“native 插件无需持久 hook 日志即可使用类型化 Decision”;旗舰 native 插件实践示例恰好断言了这一点(完全没有 `hook/*` 事件)。 -2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有 merge fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本无物可抑制:hook stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此 hook 作者设置 `suppressOutput: true` 得到的是无声的空操作,且无任何警告。 +1. **`HookDialect` 的 `'native'` 变体**(`packages/hooks/hook-protocol/src/types.ts`)没有生产者——bridge 会标记 `'claude'` 和 `'codex'`;所有位置中唯一构造 `'native'` 的是该库自己的单元测试。字段自身的 JSDoc 将 `dialect` 定义为“运行它的 bridge”,而 native 不是 bridge:[拦截 seam Agent Note](../feature/2026-06-30-interception-seams.md) 记载 native 钩子不是一个包,并且“native 插件无需持久钩子日志即可使用类型化 Decision”;旗舰 native 插件实践示例恰好断言了这一点(完全没有 `hook/*` 事件)。 +2. **`HookOutput.suppressOutput`**(同一文件)被 codec 解析后在所有路径上均被丢弃:没有 bridge 分支处理它、没有合并 fold、没有 warn、没有 deferred-list 行——在所有「被解析但未兑现」的同类字段中它是唯一没有明确延期声明的(`updatedInput` → 一条 warn 日志加 [pre-tool-input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md);`systemMessage` → 一条 warn 日志加 README deferred 行;`continue`/`stopReason` → 一个 `TODO(hook-continue-false)` 锚点加 `'stop'` decision 记录)。从结构上看根本无物可抑制:钩子 stdout 从不进入任何 transcript(文本记录)(上下文仅通过 `additionalContext` 流入;日志只记录 `decision`/`stderrSummary`),因此钩子作者设置 `suppressOutput: true` 得到的是无声的空操作,且无任何警告。 3. **`defaultTimeoutMs` 在两个 bridge 配置中以浮动字面量双重默认**——schema 的 `.default(600_000)` 加上一个 `?? 600_000` 回退(`packages/hooks/hooks-claude/src/index.ts`、`packages/hooks/hooks-codex/src/index.ts`),一个协议级常量在每个 bridge 中有两个归属地,两个 bridge 可能在共享默认值上悄然分歧。*提案最初的补救措施是彻底删除该旋钮,但被 no-hardcoded-tunables 审计所取代:审计保留了该旋钮作为 bridge 拥有的显式配置(并在旁边新增了 `stderrSummaryMaxChars`);剩下要修的是字面量的归属地。* 4. **`hook/result` 的语义存在于两个 bridge 中(各一份),而非拥有该事件的 lib。** `summarize()`——stderr 截断规则——在 `packages/hooks/hooks-claude/src/index.ts` 与 `packages/hooks/hooks-codex/src/index.ts` 中逐字节相同;decision 字符串规则 `output.decision ?? (output.continue === false ? 'stop' : 'pass')` 同样如此。然而 `dsh-hook-protocol` 声明了 `hook/result`、在文档中将 `stderrSummary` 描述为「已截断」却不拥有截断逻辑,记录了 decision 值却不拥有映射逻辑。如果某个 bridge 漂移(不同的上限、不同的回退),共享持久化事件的语义就会悄然分叉。 @@ -25,7 +25,7 @@ Status: implemented ## 验证 -`HookDialect` 仅包含 Claude 和 Codex,`suppressOutput` 在源码、已解析字段文档和归一化逻辑中均不存在。`durationMs` 保留在事件和 fixture(测试前置数据)中,回放时做清洗。`600_000` 和 `500` 两个默认值各只在协议库中出现一次;per-hook 超时覆盖仍然生效;两个 bridge 的测试套件均验证了由库拥有的 stderr 截断和 decision 规则。 +`HookDialect` 仅包含 Claude 和 Codex,`suppressOutput` 在源码、已解析字段文档和归一化逻辑中均不存在。`durationMs` 保留在事件和 fixture(测试前置数据)中,回放时做清洗。`600_000` 和 `500` 两个默认值各只在协议库中出现一次;每个钩子的超时覆盖仍然生效;两个 bridge 的测试套件均验证了由库拥有的 stderr 截断和 decision 规则。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml index 12bf20b67f..0aa39a18ff 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-trim-acp-bridge-unreachable-surface.md: ce0c623930192d02c8347c3956c497ee13048904 -2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: 31027db1f3b5f79a9566a2fa3ccda787b52960e7 +2026-07-04-trim-acp-bridge-unreachable-surface.zh.md: e89b0e7c9c7c8438fb71adacf1b525a0b94bf04b diff --git a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md index 31027db1f3..e89b0e7c9c 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md @@ -8,7 +8,7 @@ Status: implemented `dsh-acp` 有两处对外表面在任何已交付的配置中都不可达: -1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已发布应用包只向 bridge 传递 `{ model }`(`packages/examples/acp-demo/src/index.ts`),因此没有任何叶子 `cordis.yml`——唯一的生产配置表面——能够设置这些配置项;只有直接挂载 bridge 才能设置它们,而这种做法只存在于一个单元测试中。每份快照预期输出——包括 hook 矩阵场景——都固定 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对配置项还带有一个尚未解决的 `TODO(double-default)`:字面量存在两次(schema `.default(...)` 加 `??` 后备值),TODO 要求为它们选择一个归属。 +1. **`AcpConfig.agentName` / `agentVersion`**(`packages/ui/acp/src/index.ts`)。已发布应用包只向 bridge 传递 `{ model }`(`packages/examples/acp-demo/src/index.ts`),因此没有任何叶子 `cordis.yml`——唯一的生产配置表面——能够设置这些配置项;只有直接挂载 bridge 才能设置它们,而这种做法只存在于一个单元测试中。每份快照预期输出——包括钩子矩阵场景——都固定 schema 默认值(`deepseek-harness-acp` / `0.0.1`)。这对配置项还带有一个尚未解决的 `TODO(double-default)`:字面量存在两次(schema `.default(...)` 加 `??` 后备值),TODO 要求为它们选择一个归属。 2. **`toolKindFor` 名称启发式**(同一文件)在通用回退路径中对 `bash*`/`read*`/`write`/`edit*` 工具名做了特殊处理。自[render-intent 联合类型](../architecture/2026-07-02-tool-render-intent-union.md)以来,这些分支匹配到的每个第一方工具都自带 `presentCall` 并携带其 kind,而没有 presenter 的生产工具(`subagent`、`subagent_fork`)本来就落入 `other`。这些分支只有在工具拒绝自行呈现调用时才在生产中可达:`presentCall` 抛出异常(容错回退),或模型参数未通过工具 schema 导致 `defineTool` 的 `presentCall` 包装层返回 `undefined`(例如 `bash` 调用缺少必需的 `description`)。而桥接层自身的模块文档明确声明了该启发式所违反的设计规则:"桥接层绝不对工具名做特殊处理"。 ## 决策 diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml index 50343fef96..64bea6aec8 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-simplify-session-log-representation.md: a40f4013a97a9c940012dbb37d59beb2faf8fb22 -2026-07-12-simplify-session-log-representation.zh.md: a880a74cf41ca7d06f2278792e1abb4ea46ea969 +2026-07-12-simplify-session-log-representation.zh.md: a4ecd8c7340affda71d95505ab44910539e36bb9 diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md index a880a74cf4..a4ecd8c734 100644 --- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md @@ -10,7 +10,7 @@ Status: implemented `SurfaceManager` 同时在数组、seq map 和可变 `prev`/`next` 链接中存储相同顺序。生产代码从不读取任一链接:compact 的工具配对 balance 根据按 surface 顺序缓存的每个切点 balance 作答。替换已经使用 `indexOf`,因此链接并未使其主导操作成为常数时间。使用线性替换查找的 seq 数组具有相同的渐近替换成本,却只有一种表示需要验证。 -请求头子系统实现了一套自定义的 system/tool 增量编解码器和传输决策层,尽管其契约声明增量只是编码优化,而非可重建性要求。在每个 agent loop(智能体循环)实例边界保留初始/恢复的完整快照,然后在该实例的组装头发生变化时写入一条规范的完整 `request/header`,即可保留回放能力,同时删除 `SystemDelta`、`ToolsDelta`、往返回退逻辑以及持久化的 `request/header-delta` 变体。编解码器专属的词汇随编解码器一起消失,并非因为其各分支本身无效。 +请求头子系统实现了一套自定义的系统/工具增量编解码器和传输决策层,尽管其契约声明增量只是编码优化,而非可重建性要求。在每个 agent loop(智能体循环)实例边界保留初始/恢复的完整快照,然后在该实例的组装头发生变化时写入一条规范的完整 `request/header`,即可保留回放能力,同时删除 `SystemDelta`、`ToolsDelta`、往返回退逻辑以及持久化的 `request/header-delta` 变体。编解码器专属的词汇随编解码器一起消失,并非因为其各分支本身无效。 实现保留追加与替换 `sourceEventSeqs`、崩溃修复 provenance,以及所有 `SessionStartSource` 变体,因为这些字段承担审计/拦截职责,当前没有读取方并不能推翻这一点。 @@ -20,7 +20,7 @@ Status: implemented 请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。 -`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一响亮失败边界;ACP(Agent Client Protocol)快照 harness 则把合法的 session 中途变更表示为完整固定请求头和完整可读 prompt。 +`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一响亮失败边界;ACP(Agent Client Protocol)快照 harness 则把合法的会话中途变更表示为完整固定请求头和完整可读提示词。 ## 曾考虑的替代方案 @@ -28,7 +28,7 @@ Status: implemented ## 验证 -单元覆盖率固定有序 surface 的追加/替换行为、工具配对、压缩、完整请求头 fold/记录、请求重建和开发不变量。Seed 验证以及 JSONL、SQLite 加载测试会在重放前拒绝旧事件。无密钥 ACP 套件以新形状覆盖记录、刷新、重放、变化请求头固定,以及 sandbox 模式切换 fixture(测试前置数据)。 +单元覆盖率固定有序 surface 的追加/替换行为、工具配对、压缩、完整请求头 fold/记录、请求重建和开发不变量。Seed 验证以及 JSONL、SQLite 加载测试会在重放前拒绝旧事件。无密钥 ACP 套件以新形状覆盖记录、刷新、重放、变化请求头固定,以及沙箱模式切换 fixture(测试前置数据)。 ## 后果 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index c25a55cc71..15551560ba 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-acp-snapshot-tests.md: 43900632c4e5e3904c2d0e3b75f2a3fe3b3a50cc -2026-06-19-acp-snapshot-tests.zh.md: c3a926dfe6ed2c0228cc99a5258cbb35798591f8 +2026-06-19-acp-snapshot-tests.zh.md: a4613b19001afe87c1f6bdb61196fabbfa947a4c diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index c3a926dfe6..a4613b1900 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -14,17 +14,17 @@ Status: implemented ## 决策 -快照测试会启动真实 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将规范化输出与已提交的预期输出比较。从真实 API 一次记录的 session log 为后续所有模型流提供数据。fixture 就是产品普通的持久化 JSONL。 +快照测试会启动真实 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将规范化输出与已提交的预期输出比较。从真实 API 一次记录的会话日志为后续所有模型流提供数据。fixture 就是产品普通的持久化 JSONL。 ### fixture 即持久化的会话 JSONL -每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通 session 产物同时充当重放来源和行为预期输出。 +每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通会话产物同时充当重放来源和行为预期输出。 当场景固定另一种物理存储布局时,其 fixture 会从真实的未打包对应项机械派生。场景测试要求包含每一种预期存储行类型,并在解码后逐事件精确相等;随后,普通重放与日志比较才会证明组合后的进程能够消费并复现该布局。 ### 回放从日志推导模型脚本 -`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束 chunk 也无需特殊处理。 +`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的分片分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束分片也无需特殊处理。 ### 内存中的回放条目遵守完整的 LLM 契约 @@ -36,7 +36,7 @@ Status: implemented | { kind: 'hang' } ``` -日志推导出 chunk 条目。流开始前的抛出和挂起没有可重建的 chunk 表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀 chunk 以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。 +日志推导出分片条目。流开始前的抛出和挂起没有可重建的分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。 ### 位置式回放,单个在途流 @@ -44,24 +44,24 @@ Status: implemented ### 录制采集日志;无密钥回放需要无提供方的配置 -记录模式使用真实 `llm-deepseek` adapter 和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。 +记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。 -重放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实 adapter,同时保留实时组合。记录使用普通配置和由 harness 提供的持久化根目录。重放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发实时调用。参见[单一来源配置 Agent Note](2026-07-04-single-source-acp-replay-config.md)。 +重放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实适配器,同时保留实时组合。记录使用普通配置和由 harness 提供的持久化根目录。重放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发实时调用。参见[单一来源配置 Agent Note](2026-07-04-single-source-acp-replay-config.md)。 ### 两个表面:归一化后比对 快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的: 1. **stdout transcript**——编辑器看到的、经过 framing 的 `session/update` JSON-RPC。用于捕获 ACP bridge 中事件→更新转换(`streamSessionEventUpdate`)的回归。与已提交的 `stdout.expected.jsonl` 比较。 -2. **重新持久化的 session JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。Prompt 文本会被清理;按照[请求头固定 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读 prompt 与工具内容。Override 场景仅从其 sidecar 派生模型行为。 +2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词文本会被清理;按照[请求头固定 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读提示词与工具内容。Override 场景仅从其 sidecar 派生模型行为。 -两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的 loop、tool 和 boundary 结构。 +两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的 loop、工具和 boundary 结构。 -规范化会替换 session、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化 session 相等性检查从不覆盖重放 fixture。 +规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化会话相等性检查从不覆盖重放 fixture。 ### 隔离:当前靠归一化,后续可加沙箱 -工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,sandbox executor 可以通过现有[能力 seam](../architecture/2026-06-13-capability-seams.md)替换本地后端。 +工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,沙箱执行器可以通过现有[能力 seam](../architecture/2026-06-13-capability-seams.md)替换本地后端。 ### 回放插件是独立的包 @@ -69,16 +69,16 @@ Status: implemented ### 两个子命令,回放在默认门禁中 -`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的 session log 与 stdout 预期输出。缺少 fixture 时会响亮失败。每个场景都包含 `input.json`、`stdout.expected.jsonl` 和 `session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。 +`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的会话日志与 stdout 预期输出。缺少 fixture 时会响亮失败。每个场景都包含 `input.json`、`stdout.expected.jsonl` 和 `session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。 ## 曾考虑的替代方案 -- **手工编写包含模型 chunk 的 `llm.json`**——早期草案;复用真实 session log,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 +- **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 - **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 -- **从 `turn/end {kind:'error'|'aborted'}` 合成 throw/cancel 条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 +- **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 ## 后果 -新测试层为每个场景增加经过评审的输入、session、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、输入、workspace、规范化和重放 harness 也可以支持 ACP 之外的示例。 +新测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、输入、workspace、规范化和重放 harness 也可以支持 ACP 之外的示例。 -本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生 session *消息历史*(内部一致性不变量),而快照测试固定*外部协议输出*。两者相互补充——一个守护事件溯源不变量,另一个守护面向编辑器的契约。 +本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而快照测试固定*外部协议输出*。两者相互补充——一个守护事件溯源不变量,另一个守护面向编辑器的契约。 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index abc6579956..0ca80ac88b 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-19-real-api-e2e-ci.md: a9289980ada8ab6390b5daa488f07ec258db1bd5 -2026-06-19-real-api-e2e-ci.zh.md: bb3dd888dbdf8f725953d300c96d875cadae8673 +2026-06-19-real-api-e2e-ci.zh.md: dcb5759d26e7680d392a4855665b2eb88551ba79 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index bb3dd888db..dcb5759d26 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACP(Agent Client Protocol)inject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实编辑器 session 却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多轮次、恢复、ACP-over-stdio。 +根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACP(Agent Client Protocol)inject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实编辑器会话却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多轮次、恢复、ACP-over-stdio。 默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。 diff --git a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml index e01a578a83..9ea114c43e 100644 --- a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-redundant-snapshot-log-expected-output.md: c2452f971d3cb76dceb766072dbc0a5c81465e78 -2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md: b584bfa0154c71c1bbb683c3041dc7baf7a3548e +2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md: e6175181589eabae064c34044f353efae537961b diff --git a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md index b584bfa015..e617518158 100644 --- a/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md +++ b/.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md @@ -8,17 +8,17 @@ Status: implemented 驱动模型的 ACP(Agent Client Protocol)快照场景同时包含 `session.jsonl` 和 `session.expected.jsonl`。对于普通记录场景,`session.jsonl` 是从真实运行采集的重放 fixture(测试前置数据);重放测试会规范化新持久化的日志,并将其与 `session.expected.jsonl` 比较。在当前 fixture 中,普通记录场景的两份规范化日志完全相同。 -手工编写的 override 场景(`error-finish`、`cancel`)目前使用 `replay.override.json` 驱动模型行为,并把 `session.jsonl` 保留为最小 dummy fixture,而 `session.expected.jsonl` 存放预期的持久化日志。override 文件是由 `ReplayEntry` 对象组成的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }`、`{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }` 或 `{ "kind": "hang" }`。这种拆分同样没有必要:override sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 取得模型 chunk,因此 `session.jsonl` 仍可作为场景的预期 session log 产物。 +手工编写的 override 场景(`error-finish`、`cancel`)目前使用 `replay.override.json` 驱动模型行为,并把 `session.jsonl` 保留为最小 dummy fixture,而 `session.expected.jsonl` 存放预期的持久化日志。override 文件是由 `ReplayEntry` 对象组成的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }`、`{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }` 或 `{ "kind": "hang" }`。这种拆分同样没有必要:override sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 取得模型分片,因此 `session.jsonl` 仍可作为场景的预期会话日志产物。 ## 决策 -彻底移除 `session.expected.jsonl` 概念。每个场景最多只有一个已提交 session log 产物,即 `session.jsonl`: +彻底移除 `session.expected.jsonl` 概念。每个场景最多只有一个已提交会话日志产物,即 `session.jsonl`: - 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行归一化后的持久化日志与归一化后的 `session.jsonl` 进行比较。 - 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时,回放适配器不从 fixture 获取模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。 - 对于无模型场景,`session.jsonl` 可保留为引导 `llm-replay` 所需的最小 fixture;除非场景创建了持久化会话,否则无需进行会话日志比较。 -Stdout 预期输出保持不变;它们是面向编辑器的投影,与 session fixture 并不重复。 +Stdout 预期输出保持不变;它们是面向编辑器的投影,与会话 fixture 并不重复。 ## 曾考虑的替代方案 @@ -26,7 +26,7 @@ Stdout 预期输出保持不变;它们是面向编辑器的投影,与 sessio ## 验证 -快照 harness、fixture、孤立项守卫和文档中都不再出现 `session.expected.jsonl`;对于每个模型场景,快照测试都从 `session.jsonl` 派生预期 session log;手工编写 sidecar 的场景把预期生成日志提交为 `session.jsonl`,并以 `replay.override.json` 覆盖模型行为;孤立 fixture 守卫知道每种场景类型所需的文件。[ACP 快照测试 Agent Note(agent 决策记录)](2026-06-19-acp-snapshot-tests.md)描述了精简后的 fixture 集合。 +快照 harness、fixture、孤立项守卫和文档中都不再出现 `session.expected.jsonl`;对于每个模型场景,快照测试都从 `session.jsonl` 派生预期会话日志;手工编写 sidecar 的场景把预期生成日志提交为 `session.jsonl`,并以 `replay.override.json` 覆盖模型行为;孤立 fixture 守卫知道每种场景类型所需的文件。[ACP 快照测试 Agent Note(agent 决策记录)](2026-06-19-acp-snapshot-tests.md)描述了精简后的 fixture 集合。 ## 后果 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index 00899acd09..822fc921b4 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-fork-child-replay-seed-boundary.md: d3cbbb1dae1d64a10973bd5895ccc47d877eba28 -2026-06-22-fork-child-replay-seed-boundary.zh.md: 8af1ba869ba5484442a6575f78556f47abe99d22 +2026-06-22-fork-child-replay-seed-boundary.zh.md: a944538b9dcb74eb15142593089c7905efc3f565 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index 8af1ba869b..a944538b9d 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -[逐 session 快照重放 Agent Note(agent 决策记录)](2026-06-22-subagent-snapshot-replay.md)使快照层能够表达嵌套 agent 形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用 session 作为键,以独立脚本重放。它曾指出(§ 范围,最后一个项目符号),fork 快照“只是未来很容易添加的一项,并非键控缺口”。这一判断对 fork 子项而言是错误的——问题不在键控,而在*脚本派生*。 +[逐会话快照重放 Agent Note(agent 决策记录)](2026-06-22-subagent-snapshot-replay.md)使快照层能够表达嵌套 agent 形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本重放。它曾指出(§ 范围,最后一个项目符号),fork 快照“只是未来很容易添加的一项,并非键控缺口”。这一判断对 fork 子项而言是错误的——问题不在键控,而在*脚本派生*。 subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从已录制的会话日志推导:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自身的模型调用。 **fork** 子会话不同。fork 后端用*父日志的一段平衡的已完成轮次前缀*([`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess))来播种子会话,而该 seed 会成为子会话持久化的 `log`(`Session` 构造函数将 seed 复制进 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自身的轮次。 -从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段 chunk 序列而非自身的。目前已录制的场景全部是 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。 +从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段分片序列而非自身的。目前已录制的场景全部是 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。 ## 决策 @@ -22,7 +22,7 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla `SessionHeader` 新增可选字段 `seedLength: number`——表示有多少前导事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= 播种前缀的长度);全新的 spawn 子会话不设置(等同于 0)。它通过 `CreateSessionOptions.meta`(及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。 -`seedLength` 是**显式**的,绝不从 `seed.length` 推断。重建(resume/load)时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——resume 路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:重建时显式保留,而非重新默认为当前时间。) +`seedLength` 是**显式**的,绝不从 `seed.length` 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——恢复路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:恢复时显式保留,而非重新默认为当前时间。) ### 2. 两个持久化后端均完整往返 @@ -46,4 +46,4 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla - core 与两个后端新增一个持久化 header 字段;核心数据结构目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions` 的 `type-equiv` 块)。 - 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。 -- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的 chunk——推导出的子会话脚本必须排除它,不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。 +- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml index e6fb378eb3..2e87edf31e 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-fork-snapshot-scenarios.md: 46c688a4095a1d8af32b3b99887929f71a1526ce -2026-06-22-fork-snapshot-scenarios.zh.md: 4fe184685d4cfaa6f096af33d782ce3a1391b877 +2026-06-22-fork-snapshot-scenarios.zh.md: 8823611ec02f269e5a21a8065c9ed3cc3911f749 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md index 4fe184685d..8823611ec0 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md @@ -15,16 +15,16 @@ Status: implemented 针对真实 API 记录两个场景,均在默认门禁中以无密钥方式回放: - **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此可以从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的记录而非手工合成。 -- **`subagent-mixed`**——父项完成一个轮次,随后在同一 transcript 中通过 `subagent` 委托一次(全新 spawn 子项,`seedLength` 为 0),再通过 `subagent_fork` 委托一次(fork 子项,`seedLength` 非零)。这是 seed 边界与逐 session 重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(`seedLength` 为 0 = 无操作,`seedLength > 0` = 裁剪继承前缀),两个子项按 `createdAt` 排列为先 spawn、后 fork。 +- **`subagent-mixed`**——父项完成一个轮次,随后在同一 transcript 中通过 `subagent` 委托一次(全新 spawn 子项,`seedLength` 为 0),再通过 `subagent_fork` 委托一次(fork 子项,`seedLength` 非零)。这是 seed 边界与逐会话重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(`seedLength` 为 0 = 无操作,`seedLength > 0` = 裁剪继承前缀),两个子项按 `createdAt` 排列为先 spawn、后 fork。 ### 为什么需要一个已完成的第一轮次 -fork 后端使用父项的**已配平完整轮次前缀**为子项提供 seed。父项若在第一个轮次就执行 fork,没有已完成轮次可供继承,因此 seed 为空(≡ 全新 spawn,`seedLength` 为 0)——这不会覆盖 slice。因此,两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立稍后要求子项回忆的 codeword),第二个 prompt 委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 `seedLength`。 +fork 后端使用父项的**已配平完整轮次前缀**为子项提供 seed。父项若在第一个轮次就执行 fork,没有已完成轮次可供继承,因此 seed 为空(≡ 全新 spawn,`seedLength` 为 0)——这不会覆盖 slice。因此,两个场景都使用双提示词输入:第一个提示词完成一个轮次(建立稍后要求子项回忆的 codeword),第二个提示词委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 `seedLength`。 ## 后果 -- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话记录的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 -- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。 +- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话记录的分片而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。 +- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的逐会话回放键控。 - 进程外(ACP(Agent Client Protocol))subagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。 - 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在无密钥时自动跳过,与所有已录制场景一致。 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index ad4c885ad6..379054599b 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-22-subagent-snapshot-replay.md: 4aad8b6ddd3e4f8b8ad5565e10b263953d81ea31 -2026-06-22-subagent-snapshot-replay.zh.md: 2dfcee22463c8bb31e68bfb078915475e2bd7f78 +2026-06-22-subagent-snapshot-replay.zh.md: 96890bc98a64357d9785b8081c8ba123bb3c716e diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index 2dfcee2246..96890bc98a 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -快照层(`pnpm run test:snapshot`)会启动真实 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 重放已记录 session,并将规范化 stdout transcript(文本记录)+ 重新持久化的 session log 与已提交预期输出进行 diff。它是唯一端到端覆盖完整面向编辑器 transcript 的测试层。 +快照层(`pnpm run test:snapshot`)会启动真实 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 重放已记录会话,并将规范化 stdout transcript(文本记录)+ 重新持久化的会话日志与已提交预期输出进行 diff。它是唯一端到端覆盖完整面向编辑器 transcript 的测试层。 该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处: diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml index b3d9705b16..743d7ecbf0 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-hook-snapshot-matrix.md: ceb91a70e1f9582cf2a7cb4cec0ba8aaf5699b8e -2026-07-04-hook-snapshot-matrix.zh.md: 61c0c06569db17e7daec8d75d4af8150a1f9dd52 +2026-07-04-hook-snapshot-matrix.zh.md: 250995dd1aae566aa5b9d40a14ed281b14fbfe0b diff --git a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md index 61c0c06569..250995dd1a 100644 --- a/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md +++ b/.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Hook 快照矩阵——覆盖两种 bridge 的端到端 预期输出 测试 +# Agent Note: 钩子快照矩阵——覆盖两种 bridge 的端到端预期输出测试 Status: implemented @@ -6,47 +6,47 @@ Status: implemented ## 问题 -hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——把外部 hook 命令映射到 harness 拦截 seam。它们有深入的单元与覆盖率规格覆盖(每个决策分支、每种 payload dialect,针对 mock seam 驱动),外加一个受密钥门控的 e2e(`hooks.e2e.ts`,实时 `PreToolUse` 阻止)。但完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 子进程、无需密钥重放已记录 session,并将规范化 ACP(Agent Client Protocol)stdout + 重新持久化日志与已提交预期输出进行 diff 的那张网——只覆盖了一个 hook:Claude `UserPromptSubmit` 阻止(`hook-cc-promptsubmit-block`)。 +钩子 bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)(7 个 Claude Code 钩子点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)(5 个 Codex 点)——把外部钩子命令映射到 harness 拦截 seam。它们有深入的单元与覆盖率规格覆盖(每个决策分支、每种 payload dialect,针对 mock seam 驱动),外加一个受密钥门控的 e2e(`hooks.e2e.ts`,实时 `PreToolUse` 阻止)。但完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 子进程、无需密钥重放已记录会话,并将规范化 ACP(Agent Client Protocol)stdout + 重新持久化日志与已提交预期输出进行 diff 的那张网——只覆盖了一个钩子:Claude `UserPromptSubmit` 阻止(`hook-cc-promptsubmit-block`)。 -这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实 hook 进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个 hook 点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex hook 能端到端触发。 +这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实钩子进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个钩子点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex 钩子能端到端触发。 ## 决策 实现由两个耦合部分组成: -### 1. ACP 示例同时加载两种 hook bridge +### 1. ACP 示例同时加载两种钩子 bridge `examples/acp-agent/cordis.yml` 和 `cordis.snapshot.yml` 现在同时加载 `dsh-hooks-codex` 与 `dsh-hooks-claude`,各自指向自己的配置文件(Claude 用 `./hooks.json`,Codex 用 `./codex-hooks.json`——两种方言无法共用一个文件)。这是一个真正的产品接口变更,而非仅用于测试的接线:交付的 ACP 服务器(以及 `demo:acp` 入口)现在同时携带两种 bridge。 -这是安全的,因为配置文件不存在时 bridge 是**静默无操作**的:`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不附带 stdout logger,因此警告不会到达 ACP JSON-RPC 通道。只需要 Claude hook 的场景(或真实项目)只提供 `hooks.json`;Codex bridge 找不到 `codex-hooks.json` 便自动消失。这已通过实验验证:在两种 bridge 同时加载的情况下,所有既有快照(均不附带 `codex-hooks.json`)逐字节一致。 +这是安全的,因为配置文件不存在时 bridge 是**静默无操作**的:`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不附带 stdout logger,因此警告不会到达 ACP JSON-RPC 通道。只需要 Claude 钩子的场景(或真实项目)只提供 `hooks.json`;Codex bridge 找不到 `codex-hooks.json` 便自动消失。这已通过实验验证:在两种 bridge 同时加载的情况下,所有既有快照(均不附带 `codex-hooks.json`)逐字节一致。 同时加载是让快照层能够在产品交付的同一个真实应用上验证每种方言的最低要求。录制(启动 `cordis.yml`)天然加载两者,回放以同样方式继承:`cordis.snapshot.yml` 是 `cordis.yml` 的 include-overlay,只替换 llm 入口(见[单一来源 acp-agent 回放配置](2026-07-04-single-source-acp-replay-config.md)),因此添加到运行时树的 bridge 无需第二次编辑即出现在回放树中。 -### 2. 每个 hook 点 × 其主要结果各一个快照场景,覆盖两种方言 +### 2. 每个钩子点 × 其主要结果各一个快照场景,覆盖两种方言 `examples/acp-agent/tests/snapshots/` 下共 13 个场景,命名为 `hook-<dialect>-<point>-<outcome>`: - **手工编写、无模型轮次**(无密钥、无 sidecar——派生的回放脚本为空;比对的是携带 `hook/*` 事件的 `rejected` 轮次):`hook-cc-promptsubmit-block`、`hook-codex-promptsubmit-block`。 -- **对真实 API 录制、录制期间 hook 活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`(allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`(deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`(block 并附带反馈)、`hook-{cc,codex}-posttool-context`(accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞性 Stop hook 通过 steering(中途引导)强制多走一步)。 +- **对真实 API 录制、录制期间钩子活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`(allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`(deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`(阻止并附带反馈)、`hook-{cc,codex}-posttool-context`(accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞性 Stop 钩子通过 steering(中途引导)强制多走一步)。 -每个 hook 命令只输出固定字面量字符串(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop hook 会在每一步都 force-continue。 +每个钩子命令只输出固定字面量字符串(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop 钩子会在每一步都 force-continue。 -`PostToolUse` 阻止场景会在其证明的机制处自行限制。Claude hook 在首次拒绝后持久化一个 workspace 标记,因此允许一次恢复调用;Codex prompt 发起一次调用并报告注入结果。每份预期输出固定一次遭阻止调用,不会重复阻止/重试循环。 +`PostToolUse` 阻止场景会在其证明的机制处自行限制。Claude 钩子在首次拒绝后持久化一个 workspace 标记,因此允许一次恢复调用;Codex 提示词发起一次调用并报告注入结果。每份预期输出固定一次遭阻止调用,不会重复阻止/重试循环。 -### 三个 hook 点被有意排除在快照之外 +### 三个钩子点被有意排除在快照之外 在构建矩阵过程中发现,记录于此是因为这些遗漏是决策而非疏忽: - **`SessionStart` 和 `SubagentStart`** 通过脱离且尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有轮次绑定。由此产生的 `context/message` 会与它应先于的工作(首次模型请求 / 子项的第一个轮次)竞速,并落在不确定的日志位置。记录的预期输出甚至无法在自己的重放中复现——对两者执行 10 次重放稳定性检查,结果均为 10/10 次失败。它们继续留在 bridge 的单元覆盖率中,那里会直接驱动 seam 而不存在时序竞速。(如果注入未来改为绑定轮次且具备确定性——`TODO(session-start-gating)` 所指方向——它们就能接受快照测试。) -- **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递轮次(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无 hook 运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。 +- **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递轮次(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无钩子运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。 -因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的 hook 点,涵盖两种方言。 +因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的钩子点,涵盖两种方言。 ## 后果 - 现在,两种 dialect 中每个具有可观察 transcript 的 bridge seam 映射,都在真实应用的完整 transcript 层受到守护——包括此前完全没有端到端覆盖的 Codex bridge。记录的预期输出捕获模型对遭拒绝/遭阻止/强制继续轮次的真实反应,而手工编写的 transcript 只能猜测这种反应。 - `UserPromptSubmit` 阻止场景无需密钥即可编写(没有模型轮次);其余场景从已记录 fixture(测试前置数据)无需密钥重放。`pnpm run test:snapshot:record` 从实时 API 重新生成记录式 fixture,并像所有记录场景一样在缺少密钥时自行跳过。 -- 证明会变红的准则仍成立:篡改 hook 配置输出(例如改变拒绝理由)会让相应场景在重放时变红——hook 进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际 hook→seam→循环路径,而非其 mock。 +- 证明会变红的准则仍成立:篡改钩子配置输出(例如改变拒绝理由)会让相应场景在重放时变红——钩子进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际钩子→seam→循环路径,而非其 mock。 - `acp-agent` 演示现在加载了一个通常会无操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml index 0aba628ae2..2b7c1cc937 100644 --- a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-pin-request-header-content-in-one-scenario.md: bca6d9eb943e758d68efaf3a76ec367179cd15fd -2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 9637602aca34977bee7c0efd5dc57b848ed93e2c +2026-07-06-pin-request-header-content-in-one-scenario.zh.md: e01c84c63583e2fe14b0b4fe0381a18b209d2346 diff --git a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md index 9637602aca..e01c84c635 100644 --- a/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md +++ b/.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -每种请求头组合类别恰好有一个场景标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.expected.md` 以普通 Markdown 包含规范化的完整 prompt 序列;`tool-schemas.expected.json` 以结构化 JSON 包含对应的完整 schema 序列;`session.jsonl` 保留 config、reason 和所有模型可见前缀,同时将 `header.system` 与 `header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其他每份 JSONL 都使用相同的 prompt 与工具 token,并同样将 session 前缀内容 token 化。固定机制位于 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md),其套件 factory 强制每种类别恰好有一个固定场景。 +每种请求头组合类别恰好有一个场景标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.expected.md` 以普通 Markdown 包含规范化的完整提示词序列;`tool-schemas.expected.json` 以结构化 JSON 包含对应的完整 schema 序列;`session.jsonl` 保留 config、reason 和所有模型可见前缀,同时将 `header.system` 与 `header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其他每份 JSONL 都使用相同的提示词与工具 token,并同样将会话前缀内容 token 化。固定机制位于 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md),其套件 factory 强制每种类别恰好有一个固定场景。 -纯 `scrubSystemPrompts` 和 `scrubToolSchemas` 规范化器会分别将每个已存储完整请求头 token 化。`scrubRequestHeaders` 还会为非固定场景把 session 前缀内容 token 化,同时保留请求头数量、字段存在性、config、reason 和前缀消息数量。记录与刷新写回会在写入 JSONL 前应用适当清理,并根据规范化的实时完整请求头序列重新生成两个 sidecar,因此两条路径都无法把大段 prompt/schema 重新引入 JSONL,也不会留下陈旧的评审产物。 +纯 `scrubSystemPrompts` 和 `scrubToolSchemas` 规范化器会分别将每个已存储完整请求头 token 化。`scrubRequestHeaders` 还会为非固定场景把会话前缀内容 token 化,同时保留请求头数量、字段存在性、config、reason 和前缀消息数量。记录与刷新写回会在写入 JSONL 前应用适当清理,并根据规范化的实时完整请求头序列重新生成两个 sidecar,因此两条路径都无法把大段提示词/schema 重新引入 JSONL,也不会留下陈旧的评审产物。 -守卫使这种拆分能够自我强制。在磁盘上,每个 `session*.jsonl` 都是 prompt 和 schema 清理器的固定点;只有非固定 fixture(测试前置数据)必须是完整请求头清理的固定点;两个 sidecar 恰好位于固定 fixture 旁,并采用规范、以换行符结尾的格式;每种类别都有一个固定场景。在实时运行中,由父项、spawn 子项、fork 子项、初始请求、恢复或实例内变化产生的每个 `request/header`,都必须在易变值规范化后与重建的类别序列匹配。请求头若没有字符串 prompt、没有数组值工具列表,或超过固定场景声明的变更请求头数量,就会响亮失败。 +守卫使这种拆分能够自我强制。在磁盘上,每个 `session*.jsonl` 都是提示词和 schema 清理器的固定点;只有非固定 fixture(测试前置数据)必须是完整请求头清理的固定点;两个 sidecar 恰好位于固定 fixture 旁,并采用规范、以换行符结尾的格式;每种类别都有一个固定场景。在实时运行中,由父项、spawn 子项、fork 子项、初始请求、恢复或实例内变化产生的每个 `request/header`,都必须在易变值规范化后与重建的类别序列匹配。请求头若没有字符串提示词、没有数组值工具列表,或超过固定场景声明的变更请求头数量,就会响亮失败。 一个固定场景覆盖整个套件,因为每个会话(parent、spawn 子会话、fork 子会话)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合将来在设计上变为会话相关的(例如受限的 subagent 工具集),那么分歧的形态将获得自己的固定场景。 @@ -24,11 +24,11 @@ Status: implemented - **仅在比较时 scrub,fixture 保持原始内容**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时会整体重写。存储 token 诚实地表明每个 JSONL 没有固定什么。 - **全部 scrub,不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema)的唯一端到端记录。生成的工具目录只孤立地记录每个工具;只有真实 fixture 才能固定组合后的完整集合。 - **将完整固定内容全部保留在 JSONL 中**:消除了套件范围的重复,但提示词和 schema 变更仍然是一行转义文本。Markdown 和结构化 JSON 为每种内容提供其自然的评审格式,同时不削弱重建 header 的断言。 -- **收窄 session log 本身(记录内容 digest,把请求头存到其他位置)**——违反可重建性契约:产品日志必须逐 bit 复现每个请求([可重建请求 Agent Note(agent 决策记录)](../architecture/2026-07-05-reconstructable-requests.md))。请求头体积是测试产物问题,应在测试规范化中解决;实时日志保持不变。 +- **收窄会话日志本身(记录内容 digest,把请求头存到其他位置)**——违反可重建性契约:产品日志必须逐 bit 复现每个请求([可重建请求 Agent Note(agent 决策记录)](../architecture/2026-07-05-reconstructable-requests.md))。请求头体积是测试产物问题,应在测试规范化中解决;实时日志保持不变。 ## 验证 -该套件针对拆分后的固定内容重放每个场景。单元覆盖率会覆盖独立与完整清理器、两种完整请求头 sidecar 格式、记录/刷新重新生成、规范化 prompt/schema 提取、固定点强制、必需文件对称性、重建请求头一致性,以及变更请求头数量拒绝。 +该套件针对拆分后的固定内容回放每个场景。单元覆盖率会覆盖独立与完整清理器、两种完整请求头 sidecar 格式、记录/刷新重新生成、规范化提示词/schema 提取、固定点强制、必需文件对称性、重建请求头一致性,以及变更请求头数量拒绝。 ## 后果 diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml index 4c2aa1c60c..5c5bad0b1a 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-shared-acp-snapshot-package.md: 3e5a2b12114d535490a17361128862f6d1c09a73 -2026-07-08-shared-acp-snapshot-package.zh.md: c6b943d93e58c714b52d05d1c9b352e7fb8e2205 +2026-07-08-shared-acp-snapshot-package.zh.md: 072ef692702cb769c428f8b6aa3863a3b77d3d59 diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md index c6b943d93e..072ef69270 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md +++ b/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -6,21 +6,21 @@ Status: implemented ## 问题 -ACP(Agent Client Protocol)快照层([快照 Agent Note(agent 决策记录)](2026-06-19-acp-snapshot-tests.md))由位于一个示例测试目录内的三个模块构建:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,采集持久化日志)、`snapshot-normalize.ts`(纯预期输出规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(记录/重放模式、stdout 预期输出与日志比较、固定请求头一致性守卫、孤立项/必需文件/单一固定项元测试)。 +ACP(Agent Client Protocol)快照层([快照 Agent Note(agent 决策记录)](2026-06-19-acp-snapshot-tests.md))由位于一个示例测试目录内的三个模块构建:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,采集持久化日志)、`snapshot-normalize.ts`(纯预期输出规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(记录/回放模式、stdout 预期输出与日志比较、固定请求头一致性守卫、孤立项/必需文件/单一固定项元测试)。 -第二个希望获得快照覆盖的 ACP 示例——直接消费方是 sandbox/approval 组合——只能复制这些模块,恰好分叉了绝不能漂移的逻辑:记录写回、请求头清理、子 session 采集顺序。spawn/client 胶水也在 `acp.e2e.ts`、`hooks.e2e.ts` 和 harness 中重复三份。文件位置决定了测试严格度:逐文件 100% 覆盖率门禁只测量 `packages/*/*/src`,因此这些机制完全未被测量——正是同一种缺口,曾推动 `dsh-llm-replay` 从 `examples/` 移入 [packages/support](../../../../packages/support/README.md)。此外,harness 的 ACP client 硬编码 `requestPermission → cancelled`,因此 approval 往返——sandbox 组合的主打行为——完全无法在快照层表达。 +第二个希望获得快照覆盖的 ACP 示例——直接消费方是沙箱/approval 组合——只能复制这些模块,恰好分叉了绝不能漂移的逻辑:记录写回、请求头清理、子会话采集顺序。spawn/client 胶水也在 `acp.e2e.ts`、`hooks.e2e.ts` 和 harness 中重复三份。文件位置决定了测试严格度:逐文件 100% 覆盖率门禁只测量 `packages/*/*/src`,因此这些机制完全未被测量——正是同一种缺口,曾推动 `dsh-llm-replay` 从 `examples/` 移入 [packages/support](../../../../packages/support/README.md)。此外,harness 的 ACP client 硬编码 `requestPermission → cancelled`,因此 approval 往返——沙箱组合的主打行为——完全无法在快照层表达。 ## 决策 -这些机制位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)(`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,依赖自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` overlay([单源 replay 配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在边缘层——库接收的是已解析的 `mode`。 +这些机制位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)(`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,依赖自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` overlay([单源回放配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在边缘层——库接收的是已解析的 `mode`。 -**`src/launcher.ts`**——`launchAcpTestAgent` 拥有通用的未构建进程边界:绝对 tsx loader 解析、`TSX_TSCONFIG_PATH`、隔离的 harness home、stdio 接线、原始字节 stdout tee、stderr 与更新捕获、失败关闭的权限后备、更新 waiter,以及优雅或信号式关闭。快照场景和普通 e2e 套件提供相同的 `AgentUnderTest`(`binScript`、`configPath`、`tsconfigPath`);扮演用户的测试只提供其权限 handler。ACP 与 hook e2e 套件以及 sandbox/approval e2e 套件都使用该 launcher,而不再重新构建 SDK client 边界。 +**`src/launcher.ts`**——`launchAcpTestAgent` 拥有通用的未构建进程边界:绝对 tsx loader 解析、`TSX_TSCONFIG_PATH`、隔离的 harness home、stdio 接线、原始字节 stdout tee、stderr 与更新捕获、失败关闭的权限后备、更新 waiter,以及优雅或信号式关闭。快照场景和普通 e2e 套件提供相同的 `AgentUnderTest`(`binScript`、`configPath`、`tsconfigPath`);扮演用户的测试只提供其权限 handler。ACP 与钩子 e2e 套件以及沙箱/approval e2e 套件都使用该 launcher,而不再重新构建 SDK client 边界。 **`src/harness.ts`**——`runScenario` 和输入脚本/结果类型在 launcher 之上叠加确定性步骤、临时 workspace、快照环境和持久化日志采集。其 `session/request_permission` handler 消费可选的 `InputScript.permissionAnswers` FIFO 队列,每个条目按选项**类型**进行选择(id 是 agent 生成的随机值,已提交脚本无法预知;类型是 ACP 稳定词汇,会在回答时映射到已提供的 `optionId`);队列不存在或耗尽时回答 `cancelled`,若请求从未提供某种类型则拒绝该次运行——agent 自身收到的回答是 `cancelled`,因此场景 bug 会使 harness 失败,而不会被吸收为 agent 侧拒绝。由此,approval 套件可以根据 `input.json` 确定性地驱动允许/拒绝往返。 **`src/normalize.ts`** 是纯规范化器,按策略不含钩子:当未来某个事件携带新的易变字段(例如审批耗时),共享规范化器在同一个变更中学会它,保持「规范化」的含义只有一个归属,而非各套件各自扩展清洗逻辑。 -**`src/suite.ts`**——包含 `Scenario` 类型和 `defineAcpSnapshotSuite(options)`,注册各场景比较、记录/刷新 fixture 写回、带实时一致性守卫的请求头固定项,以及 fixture 守卫块(没有孤立场景目录、必需文件存在、每种类别恰好一个固定项、每份 JSONL 都是 `scrubSystemPrompts` 固定点、非固定 fixture 同时也是 `scrubRequestHeaders` 固定点)。刷新会先展开打包的计时信封,再对齐现有易变事件时间,因此在打包与未打包布局之间切换不会移动后续记录;全新的 chunk fragment 数组仍为权威,因为其边界属于重放行为。场景目录中的 `session.jsonl` 加连续的 `session.<n>.jsonl` 同级文件构成有序主项/子项清单,因此场景表可以声明策略而不重复子项数量。固定请求头契约([固定请求头 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每种请求头类别恰好标记一个 `pinsHeader` 场景,其 `system-prompt.expected.md` 和 JSONL 工具列表把组合请求头拆成可评审产物;一致性守卫会将两者与该类别的每个实时请求头比较。固定场景可以声明任何合法的变更请求头数量,其 Markdown 产物记录每个完整的已变 prompt。纯辅助函数(`sessionFixtureNames`、`fixtureContext`、`normalizedHeaders`、`normalizedSystemPrompts`、`formatSystemPromptSnapshot`、`headerChangeCount`)从模块导出,以便直接进行单元覆盖。 +**`src/suite.ts`**——包含 `Scenario` 类型和 `defineAcpSnapshotSuite(options)`,注册各场景比较、记录/刷新 fixture 写回、带实时一致性守卫的请求头固定项,以及 fixture 守卫块(没有孤立场景目录、必需文件存在、每种类别恰好一个固定项、每份 JSONL 都是 `scrubSystemPrompts` 固定点、非固定 fixture 同时也是 `scrubRequestHeaders` 固定点)。刷新会先展开打包的计时信封,再对齐现有易变事件时间,因此在打包与未打包布局之间切换不会移动后续记录;全新的分片片段数组仍为权威,因为其边界属于回放行为。场景目录中的 `session.jsonl` 加连续的 `session.<n>.jsonl` 同级文件构成有序主项/子项清单,因此场景表可以声明策略而不重复子项数量。固定请求头契约([固定请求头 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每种请求头类别恰好标记一个 `pinsHeader` 场景,其 `system-prompt.expected.md` 和 JSONL 工具列表把组合请求头拆成可评审产物;一致性守卫会将两者与该类别的每个实时请求头比较。固定场景可以声明任何合法的变更请求头数量,其 Markdown 产物记录每个完整的已变提示词。纯辅助函数(`sessionFixtureNames`、`fixtureContext`、`normalizedHeaders`、`normalizedSystemPrompts`、`formatSystemPromptSnapshot`、`headerChangeCount`)从模块导出,以便直接进行单元覆盖。 ## 曾考虑的替代方案 @@ -33,7 +33,7 @@ ACP(Agent Client Protocol)快照层([快照 Agent Note(agent 决策记 ## 测试 -提取一致性得到机械证明:迁移后,`pnpm run test:snapshot` 的结果与基准提交匹配,`examples/acp-agent/tests/snapshots/` 下没有任何字节变化。包的 `src/` 在门禁单元运行中保持逐文件 100% 语句/分支/函数/行覆盖,并通过脚本化 fake ACP bin(`tests/fixtures/fake-acp-agent.ts`,每个场景由 fixture 旁的 `behavior.json` 编排行为)经过真实 launcher 驱动:`harness.spec.ts` 直接覆盖 launcher 默认值、捕获、更新等待、关闭以及环境/配置变体,随后覆盖每种场景步骤操作、两个 expect-error 分支、权限队列(选择、后备、不可能点击)、workspace seed,以及采集顺序/噪音/后备分支;`suite.spec.ts` 在收集时真实运行 factory——一个针对已提交合成 fixture 的重放套件和一个针对临时副本的记录套件(写回从不触及已提交树;`ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` 会重新引导它)——并包含纯辅助函数的直接用例。fake bin 会把 `session/new` cwd 而非 `process.cwd()` 代入脚本化日志,与真实 bin 请求头携带的内容一致(darwin 会将 `/var/folders/…` realpath 为 `/private/var/folders/…`)。 +提取一致性得到机械证明:迁移后,`pnpm run test:snapshot` 的结果与基准提交匹配,`examples/acp-agent/tests/snapshots/` 下没有任何字节变化。包的 `src/` 在门禁单元运行中保持逐文件 100% 语句/分支/函数/行覆盖,并通过脚本化 fake ACP bin(`tests/fixtures/fake-acp-agent.ts`,每个场景由 fixture 旁的 `behavior.json` 编排行为)经过真实 launcher 驱动:`harness.spec.ts` 直接覆盖 launcher 默认值、捕获、更新等待、关闭以及环境/配置变体,随后覆盖每种场景步骤操作、两个 expect-error 分支、权限队列(选择、后备、不可能点击)、workspace seed,以及采集顺序/噪音/后备分支;`suite.spec.ts` 在收集时真实运行 factory——一个针对已提交合成 fixture 的回放套件和一个针对临时副本的记录套件(写回从不触及已提交树;`ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` 会重新引导它)——并包含纯辅助函数的直接用例。fake bin 会把 `session/new` cwd 而非 `process.cwd()` 代入脚本化日志,与真实 bin 请求头携带的内容一致(darwin 会将 `/var/folders/…` realpath 为 `/private/var/folders/…`)。 ## 后果 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index a6cdff0d66..745fa459c6 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-16-typed-event-schemas.md: 97a7d0c3787eb5556696e25a6c8b1bb75642aba9 -2026-06-16-typed-event-schemas.zh.md: a02ff8ed54a1d8ea02690c4b6ceaeef4338d3123 +2026-06-16-typed-event-schemas.zh.md: bde8425839d99f7dbf7ed38eb606d1cdfec5c27f diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index a02ff8ed54..bde8425839 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -15,11 +15,11 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 由此引出问题:事件词汇是否应迁移到 **Zod** 或其他运行时 schema 库,使持久化和插件边界拥有运行时 schema 而非被擦除的类型。 -本 Agent Note 界定该问题的范围,不提出具体实现。 +本 Agent Note(agent 决策记录)界定该问题的范围,不提出具体实现。 ## 为什么这不是一个持久化层的改动 -很容易把「用 Zod 做序列化」理解为对 `dsh-session-persistence-jsonl/src/format.ts` 的局部修改。但它不是,原因在于一个结构性事实:**插件无法对 Zod schema 进行声明合并。** 声明合并是 TypeScript 编译期机制;Zod schema 是运行时值。要用 Zod 校验事件,就需要一个**运行时注册表**,每个产出事件的包(package)向其贡献自己的 schema(如 `ctx.sessionEvents.register('compaction/marker', z.object({…}))`),每个消费方从中读取。这个注册表——而非持久化后端——将成为词汇的真源,取代 merge-extensible interface。 +很容易把「用 Zod 做序列化」理解为对 `dsh-session-persistence-jsonl/src/format.ts` 的局部修改。但它不是,原因在于一个结构性事实:**插件无法对 Zod schema 进行声明合并。** 声明合并是 TypeScript 编译期机制;Zod schema 是运行时值。要用 Zod 校验事件,就需要一个**运行时注册表**,每个产出事件的包(package)向其贡献自己的 schema(如 `ctx.sessionEvents.register('compaction/marker', z.object({…}))`),每个消费方从中读取。这个注册表——而非持久化后端——将成为词汇的真源,取代 merge-extensible 接口。 因此,真正的提案是:**用运行时 schema 注册表替换编译期的 merge-extensible-map 模式,范围覆盖整个仓库。** 这是一次核心词汇的重新设计。 diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml index 1f6a7ec184..ba5d8cd8c9 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-30-pre-tool-input-rewrite.md: 5de605e7edf63046b14a125d53072aab9bb5d37e -2026-06-30-pre-tool-input-rewrite.zh.md: 7105fadd5b42c73b0493443935a9db1e37e525ab +2026-06-30-pre-tool-input-rewrite.zh.md: eab80764f5dfbd0448dabf0dc3c893ba2ec94928 diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md index 7105fadd5b..eab80764f5 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -[拦截 seam Agent Note](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 +[拦截 seam Agent Note(agent 决策记录)](../../implemented/feature/2026-06-30-interception-seams.md) 将 `tools/pre-execute` 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 `PreToolUse` 钩子还提供了 `updatedInput`,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。 ## 问题本质:执行前参数的三个读取方 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index 8bb7750ed7..e53e9f69c2 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 46bbc0023b39899bebfd6ecfe259625aeae11c26 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: bd7133a4b8e761ebeb61cea1d26631eab40f13d2 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 46bbc0023b..bd7133a4b8 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派) +# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent(智能体)的进程外委派) Status: proposed @@ -6,17 +6,17 @@ Status: proposed ## 问题 -subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP 后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为机械上相似的兄弟。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 +subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACP(Agent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为机械上相似的兄弟。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。 ## 提案 -两个兄弟提供方包(ACP 后端的结构变体),加一次提取: +两个兄弟提供方包(package),作为 ACP 后端的结构变体,另加一次提取: -- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI 作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。 +- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk` 的 `query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI(命令行界面)作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。 - `@deepseek-ai/dsh-subagent-codex`:spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread/turn,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。 -- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose 阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 +- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose(资源释放)阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。 -两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次 prompt 往返、所有能力均为 `false`、`inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions`、`id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName`(`subagent_claude_code`、`subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。 +两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次提示词往返、所有能力均为 `false`、`inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions`、`id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName`(`subagent_claude_code`、`subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。 ## 已验证的接口事实(固定版本) @@ -37,7 +37,7 @@ subagent seam([seam Agent Note](../../implemented/feature/2026-06-21-subagent- ## 权限与审批策略 -每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有漏过请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中 prompt 不会到达人类,与 ACP 一致。 +每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有漏过请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中提示词不会到达人类,与 ACP 一致。 ## StopReason 映射 @@ -49,7 +49,7 @@ Claude Code:`success` → `completed`;`error_max_turns`、`error_during_exec 依照根 AGENTS.md 规则在每个层级明确命名,并预先消除风险: -- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR 提供方清理、导出形状、子进程环境隔离断言和临时目录删除;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并讲 plain stream-json,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。 +- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR(热模块替换)提供方清理、导出形状、子进程环境隔离断言和临时目录删除;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并讲 plain stream-json,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。 - **有密钥 e2e 测试**:每个后端的真实引擎执行并由磁盘验证真实文件工作,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`,Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。 - **快照测试**:以 `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` 推迟——即 ACP 后端也推迟的独立回放形状([按会话回放 Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md));在此期间由无密钥套件提供确定性覆盖。 @@ -57,19 +57,19 @@ Claude Code:`success` → `completed`;`error_max_turns`、`error_during_exec ### 为什么不用官方 `@openai/codex-sdk` 而手写客户端? -dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),仓库先例(`hook-protocol`)是拥有薄协议核心而非包装他人的运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。 +dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式(wire format)极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),仓库先例(`hook-protocol`)是拥有薄协议核心而非包装他人的运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。 ### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)? -Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个 prompt + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 Agent Note,而非针对后端。 +Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个提示词 + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 Agent Note,而非针对后端。 ### 为什么不用登录态凭证和用户自身的配置? -继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill、MCP 服务器)会使子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打开一个隐式例外。仅 API key 加强制配置目录隔离使运行可复现;需要共享状态的部署可以有意将配置目录字段指向一个持久目录。 +继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill(技能)、MCP 服务器)会使子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打开一个隐式例外。仅 API key 加强制配置目录隔离使运行可复现;需要共享状态的部署可以有意将配置目录字段指向一个持久目录。 ### 为什么不为 Claude Code 无密钥测试注入驱动层 seam? -注入假的 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部实现——已被 spike 消除:假 CLI harness 今天能对真实固定版本的 SDK 正常工作。如果 SDK 升级破坏了 mock,无密钥套件会让升级 PR 失败,这正是门禁在发挥作用。 +注入假的 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部实现——已被 spike 消除:假 CLI harness 今天能对真实固定版本的 SDK 正常工作。如果 SDK 升级破坏了 mock,无密钥套件会让升级 PR(Pull Request)失败,这正是门禁在发挥作用。 ### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端? diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml index a7ee5ef34a..b815cfdd2a 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-interactive-side-sessions.md: dfd325babe215782c9c1cbec3fd9f874783af7ab -2026-07-08-interactive-side-sessions.zh.md: 6a1d11c0566a36fcbf882daad0b26590460e1534 +2026-07-08-interactive-side-sessions.zh.md: 9bc9d5c94fdef893134844551a594acc39a99d3b diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md index 6a1d11c056..9bc9d5c94f 100644 --- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md +++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md @@ -10,14 +10,14 @@ Status: proposed ## 提案 -**侧会话(side session)** 是一个普通的活跃会话,从源会话的最后一个已完成轮次 fork 而来,绑定到自己的 agent,定位为只读顾问,并能**合并回写**一条精简笔记。 +**侧会话(side session)** 是一个普通的活跃会话,从源会话的最后一个已完成轮次 fork 而来,绑定到自己的 agent(智能体),定位为只读顾问,并能**合并回写**一条精简笔记。 -- **Fork 并绑定:** 以父会话的平衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或 session-store 方法。 +- **Fork 并绑定:** 以父会话的平衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或会话存储方法。 - **顾问定位:** 创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。 - **合并回写:** 向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在其日志位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.md),无需新增会话事件。 -- **呈现:** 调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note 仅规定与界面无关的机制。 +- **呈现:** 调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note(agent 决策记录)仅规定与界面无关的机制。 -回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据均不在本 Agent Note 范围内。一次 live-adapter spike 已验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 +回退产品化、会话树视图、面向模型的侧会话工具,以及 `forkName`/`mergedInto` 元数据均不在本 Agent Note 范围内。一次真实适配器 spike 已验证了源日志隔离、继承上下文、多轮子会话交互,以及合并回写在父会话下一轮次中的可见性。 ## 曾考虑的替代方案 diff --git a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml index 1ad844cdb9..5c7c7e9cc1 100644 --- a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-13-stream-workflow-progress-through-tool-calls.md: 668d38b38cab90f6ee9b613d1101d4d44302aa8a -2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: a6d9540eb8c20d785d1395951a131feecd8ba39f +2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: b0daba0c62c9b06f33f5e3c6319f90670941e762 diff --git a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md index a6d9540eb8..b0daba0c62 100644 --- a/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md +++ b/.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -工作流引擎有意为 run、phase、narration 和子 agent(智能体)进度发出成对的 `workflow/*` observation 事件,但目前没有生产消费方呈现这些事件。因此,编辑器在最终结果返回之前只显示一张 pending 状态的工作流工具卡片,尽管引擎已经报告了当前活跃的 phase、脚本日志内容以及哪些子 agent 已启动或已结束。[dynamic-workflows 决策](../../implemented/feature/2026-07-05-dynamic-workflows.md)明确将 ACP(Agent Client Protocol)进度 UI 保留给这一事件流。 +工作流引擎有意为 run、phase、narration 和子 agent(智能体)进度发出成对的 `workflow/*` observation 事件,但目前没有生产消费方呈现这些事件。因此,编辑器在最终结果返回之前只显示一张 pending 状态的工作流工具卡片,尽管引擎已经报告了当前活跃的 phase、脚本日志内容以及哪些子 agent 已启动或已结束。[动态工作流决策](../../implemented/feature/2026-07-05-dynamic-workflows.md)明确将 ACP(Agent Client Protocol)进度 UI 保留给这一事件流。 如果让 `dsh-acp` 直接监听工作流事件,就会反转能力边界:通用的 UI 桥接层将依赖一个可选的工作流包(package),并对一个工具名做特殊处理。工具流水线已经拥有实时更新所需的路由信息(agent 和 call id),但只暴露了纯粹的 pending/final 展示器,因此长时间运行的工具没有提供方无关的方式在二者之间报告瞬态 UI 状态。 @@ -14,7 +14,7 @@ Status: proposed 为 `dsh-tools` 添加一条实时进度通道。注册表所有的 `ToolExecution` 新增 `reportProgress(view): boolean`,其中 `view` 是一个独立的、提供方无关的通用进度快照,包含可选的替换标题和面向 UI 的内容块。进度不能更改调用的 args 派生卡片标签、kind、原始输入、locations、terminal intent 或 diff intent;它只更新在最初选定的展示方式内的实时标题/内容。当执行处于活跃状态时,该方法校验并快照 view,然后分发一个受限的、agent 作用域的 `tools/progress` observation,携带权威的执行标识与快照。一旦 final-result 处理开始,方法返回 `false` 且不再分发,因此迟到的异步报告者无法覆盖终态卡片。观察者异常会被记录日志,不会导致工具失败。 -`dsh-acp` 以通用方式消费 `tools/progress`。它通过既有的 agent-to-session 映射解析执行所属的 agent,并为同一 call id 发出 in-progress 的 `tool_call_update`。由于报告仅在工具执行流水线内可用,持久化的 `tool/call` 及其 ACP `tool_call` 始终先于第一条 update;在 `tools/result` 之前关闭报告者,确保进度更新不会出现在 completed/failed 卡片之后。进度是实时 UI 状态,而非模型输入或持久历史:会话回放继续从 `tool/call` 和 `tool/result` 重建 pending 与 final 卡片,无需重放瞬态更新。 +`dsh-acp` 以通用方式消费 `tools/progress`。它通过既有的 agent 到会话映射解析执行所属的 agent,并为同一 call id 发出 in-progress 的 `tool_call_update`。由于报告仅在工具执行流水线内可用,持久化的 `tool/call` 及其 ACP `tool_call` 始终先于第一条 update;在 `tools/result` 之前关闭报告者,确保进度更新不会出现在 completed/failed 卡片之后。进度是实时 UI 状态,而非模型输入或持久历史:会话回放继续从 `tool/call` 和 `tool/result` 重建 pending 与 final 卡片,无需重放瞬态更新。 `dsh-tool-workflow` 成为第一个生产者。每次工具执行在调用 `ctx.workflows.start()` 之前安装一个紧凑的事件捕获器,因为合法的引擎可能在 `start()` 内部同步发出进度。在调用返回之前,捕获器将观察到的事件按 `WorkflowRunInfo.id` 归约为候选状态;随后选取返回的 `WorkflowRun.id`,丢弃其他候选,报告累积的快照,并将后续匹配事件直接路由。如果 `start()` 抛出异常,捕获器被 dispose(资源释放),其候选状态被丢弃。这在不向 `WorkflowStartRequest` 添加观察者关联、也不要求进度等到 `start()` 返回的前提下,保持了引擎的可替换性。 @@ -24,7 +24,7 @@ Status: proposed ## 曾考虑的替代方案 -**删除工作流 observation 表面。** 在 [collapse-workflow 简化提案](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md)中被否决:这些事件及其成对生命周期是有意设计的,缺少的是消费方。 +**删除工作流 observation 表面。** 在[折叠工作流简化提案](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md)中被否决:这些事件及其成对生命周期是有意设计的,缺少的是消费方。 **让 ACP 直接了解工作流。** 这可以将 `WorkflowRunInfo` 映射到会话和卡片,但会使通用桥接层依赖一个可选能力,并绕过「工具拥有展示意图」的规则。工具进度通道为每个长时间运行的工具解决了相同的路由问题。 @@ -36,7 +36,7 @@ Status: proposed - ACP 将进度路由到正确的实时会话中的正确调用;不同会话中的并发工作流不能串扰,且 `tool_call_update` 不会出现在其 `tool_call` 之前或终态更新之后。 - 工作流进度显示当前 phase、最新日志行、活跃子 agent 和结果计数,同时保留所有既有 `workflow/*` 事件和 run 语义;一个在 `start()` 内部同步发出 start、phase、log、child 和 end 事件的 seam 测试引擎不会丢失任何归约器状态。 - 取消、worker 死亡、工具失败、会话关闭和插件 dispose 释放归约器状态;回放仅发出持久的 pending/final 卡片对。 -- 单元测试、工作流集成测试、ACP 集成测试、快照、类型检查、覆盖率、doc-sync、module-graph、构建和 hygiene 门禁全部通过。 +- 单元测试、工作流集成测试、ACP 集成测试、快照、类型检查、覆盖率、doc-sync(文档同步门禁)、module-graph、构建和 hygiene 门禁全部通过。 ## 风险 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 3affcaabfa..744078f75d 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-dead-core-spine-surface.md: 432aaa848540fe7d1db3039399345a02afed5bd3 -2026-07-04-prune-dead-core-spine-surface.zh.md: 6352e2ea1ec6ef4459fc07472e5dbdb6cfb648a5 +2026-07-04-prune-dead-core-spine-surface.zh.md: 9e2203fab4fc0b2b0bec6ff956a3b572c0c9062a diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 6352e2ea1e..9e2203fab4 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -13,15 +13,15 @@ Status: proposed | 接口 | 生产证据 | 简化方式 | | --- | --- | --- | | `SurfaceManager.invalidate()` | 只有其单元测试调用它;seeding 在惰性创建的 manager 存在之前就已完成,且会话从不替换其日志引用。 | 删除它及其不可能触发的整体替换契约。 | -| `ToolExecutionResult.callId` | 每个钩子已经接收不可变的 `ToolExecution`;循环和 ACP(Agent Client Protocol)通过 call/session 事件关联。没有消费方读取这个重复的结果字段。 | 移除该字段、复制/不匹配守卫,以及证明该重复不可能不一致的测试。 | +| `ToolExecutionResult.callId` | 每个钩子已经接收不可变的 `ToolExecution`;循环和 ACP(Agent Client Protocol)通过调用/会话事件关联。没有消费方读取这个重复的结果字段。 | 移除该字段、复制/不匹配守卫,以及证明该重复不可能不一致的测试。 | | `ReactLoopAgent` 根导出 | 包外的命名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 返回/接口类型为 `Agent`,将具体循环类改为包内部;保留有意设计的同步、仅配置的 `AgentLoop.create()` 路径。 | -| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 每个包名消费方都使用默认引擎;workflow Agent Note 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 | +| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 每个包名消费方都使用默认引擎;工作流 Agent Note 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 | | `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 | | ACP 的 translation/presenter 根导出 | `agentOptions`、`streamSessionEventUpdate`、`todosToPlan`、`ToolPresenter`、`nullToolPresenter` 和 `TerminalRendering` 只有同文件或 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 translation/presentation 辅助函数改为源码私有,在包内测试。 | | `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 | | `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 改为源码私有;通过 spawn 和 dispose 测试。 | | `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 | -| `LlmError.status` 与 replay status | 适配器/replay 填充它,但生产分支基于稳定的 error code/message 判断,从不读取原始 status。 | 移除未读字段和 replay 管道,保留错误分类。 | +| `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 | | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 | | `compactRegion` 的独立 `session` 参数 | 固定调用者传入的对象与 `agent.session` 上已有的是同一个;模型可见的 mount API 也能调用该方法,但接受两个身份允许挂载的插件提供不一致的配对。 | 保留手动 region seam,同时有意将其收窄为以 `agent.session` 为唯一真源。 | | `CompactionResult.startSeq`、`summarySeq`、`endSeq` 与 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有 summary 和事件标识。 | 移除四个结果回显,保留两个共享的 transcript(文本记录)渲染器。 | @@ -49,15 +49,15 @@ Status: proposed **保留测试便利函数和自包含的结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更符合人体工学,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;当前它们让每处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小契约,其所有权和失败语义明确。 -**保留所有 catalog 成员以供模型编写的 mount 使用。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务接口,而非无限期保留重复字段或不一致的参数对;上述每一项 catalog 收缩都移除了在同一 execution、agent(智能体)或 result 上其他位置已可获得的事实,并在同一变更中更新 API 参考。 +**保留所有 catalog 成员以供模型编写的 mount 使用。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务接口,而非无限期保留重复字段或不一致的参数对;上述每一项 catalog 收缩都移除了在同一次执行、同一个 agent(智能体)或同一结果中其他位置已可获得的事实,并在同一变更中更新 API 参考。 ## 验收标准 - 精确符号搜索显示:在本 Agent Note 及任何已实现 Agent Note 修正之外,没有被移除的接口。 - 本 Agent Note 列出的每个接口均按指定方式缺失或降级;清单之外有意保留的扩展/测试契约不变。 -- 工具执行、上下文压缩(context compaction)、两个 LLM 适配器、两个持久化后端、workflow 隔离以及 agent 创建/恢复保持其已交付行为。 +- 工具执行、上下文压缩(context compaction)、两个 LLM 适配器、两个持久化后端、工作流隔离以及 agent 创建/恢复保持其已交付行为。 - 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建和 hygiene 通过。 ## 风险 -大多数移除在编译时可见但对运行时无影响。上下文压缩参数清理有意禁止 session/context 不匹配,同时保留手动 region seam。外部预发布嵌入者和现有模型编写的 mount 可能导入更少的辅助函数、传递更少的参数或接收更窄的结果形状;这是有意的产品接口收缩,而非仅仅是生成 catalog 的清理。仓库尚未发布,因此承载不受支持的接口才是更大的基础成本。 +大多数移除在编译时可见但对运行时无影响。上下文压缩参数清理有意禁止会话/上下文不匹配,同时保留手动 region seam。外部预发布嵌入者和现有模型编写的 mount 可能导入更少的辅助函数、传递更少的参数或接收更窄的结果形状;这是有意的产品接口收缩,而非仅仅是生成 catalog 的清理。仓库尚未发布,因此承载不受支持的接口才是更大的基础成本。 diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml index aa9ff3e5ac..8ca0473ebe 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-deterministic-and-stress-testing.md: d9977be835af05f9ee303b63ec6015bc9e153170 -2026-06-11-deterministic-and-stress-testing.zh.md: 8aed4ad6f277c1e567d91e3a8e2fef1c350ab89c +2026-06-11-deterministic-and-stress-testing.zh.md: eff9eecb699344dff388bafec270f6b6677f71ee diff --git a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md index 8aed4ad6f2..eff9eecb69 100644 --- a/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md +++ b/.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 确定性测试、回放不变式 fixture 与竞态压力测试 +# Agent Note: 确定性测试、回放不变式 fixture(测试前置数据)与竞态压力测试 Status: proposed @@ -13,7 +13,7 @@ Status: proposed 三项措施: 1. **测试中禁止挂钟睡眠。** 将 `setTimeout(N)` 等待替换为事件驱动等待(既有的 `waitForIdle` 模式,扩展为 `waitForStatus`、`waitForEvent(n)`),或在需要测试时间本身时使用 vitest 的 fake timer。通过 lint 规则禁止 `setTimeout`,适用范围是 `packages/*/tests`,白名单辅助模块除外。 -2. **通用回放 fixture(测试前置数据)。** 一个共享测试辅助函数包装 agent loop harness,使每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被套件产生的所有场景检查数百次,而非仅两次。 +2. **通用回放 fixture。** 一个共享测试辅助函数包装 agent loop harness,使每个测试结束后,agent 的会话日志被回放到一个全新的 Session 中,并自动断言 `deriveMessages()` 相等。这样该不变式在每次 CI 运行中会被套件产生的所有场景检查数百次,而非仅两次。 3. **夜间竞态压力测试。** 一个 CI job 以 `vitest --repeat=200`(加 `--shuffle`)运行 agent-loop 和 inbox 套件,以暴露调度依赖的失败;发现的任何不稳定测试都视为 bug 修复,绝不靠重试掩盖。 ## 计划 diff --git a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml index 03c09bb8df..6201a380e7 100644 --- a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml +++ b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-11-immutable-public-surfaces.md: c9009ad923720efaecb25e2017ceab6e3eb0dbf4 -2026-06-11-immutable-public-surfaces.zh.md: 7ed778505547fc91077a7e201a62123c8418e5f1 +2026-06-11-immutable-public-surfaces.zh.md: 4ef67734d712e538c5858fbc05efbc6dd983c704 diff --git a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md index 7ed7785055..4ef67734d7 100644 --- a/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md +++ b/.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md @@ -6,7 +6,7 @@ Status: rejected — 普遍采用 `DeepReadonly<T>` 的类型翻转已由 `Sessi ## 问题 -被否决的提案针对的是一个所有权漏洞:仅靠 `readonly SessionEvent[]` 类型无法封堵该漏洞,因为其元素在运行时仍然可变,类型强制转换或纯 JavaScript 代码可以改写嵌套的历史记录。已实现的设计在 `Session` 中封堵了这一漏洞:对每个被接受的事件进行物化并深度冻结,返回冻结的数组快照。进行中的 prompt waterfall(瀑布式事件)有意保持可变换,因此不可变性是一条所有权边界,而非一条全局类型规则。 +被否决的提案针对的是一个所有权漏洞:仅靠 `readonly SessionEvent[]` 类型无法封堵该漏洞,因为其元素在运行时仍然可变,类型强制转换或纯 JavaScript 代码可以改写嵌套的历史记录。已实现的设计在 `Session` 中封堵了这一漏洞:对每个被接受的事件进行物化并深度冻结,返回冻结的数组快照。进行中的提示词 waterfall(瀑布式事件)有意保持可变换,因此不可变性是一条所有权边界,而非一条全局类型规则。 ## 提案 @@ -24,6 +24,6 @@ Status: rejected — 普遍采用 `DeepReadonly<T>` 的类型翻转已由 `Sessi ## 风险 -`DeepReadonly` 类型在 waterfall 边界处(突变本身就是 API 的地方)可能产生噪音较大的错误。应将可变/只读边界精确地划在「已记录 vs 进行中」,并在 session README 中加以说明。 +`DeepReadonly` 类型在 waterfall 边界处(突变本身就是 API 的地方)可能产生噪音较大的错误。应将可变/只读边界精确地划在「已记录 vs 进行中」,并在会话 README 中加以说明。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml index 512afb19c3..6a1baa8a80 100644 --- a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml +++ b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-providerless-example-base.md: 2f41476a487775e6f9da2f113efe566e44786ff3 -2026-06-20-providerless-example-base.zh.md: ebe97db5a6cf194131239271e2713a1b0d14fc2a +2026-06-20-providerless-example-base.zh.md: e767d6b3a35dcfd52194f8a59edc496b86414b6e diff --git a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md index ebe97db5a6..e767d6b3a3 100644 --- a/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md +++ b/.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md @@ -1,6 +1,6 @@ # Agent Note: 使共享示例基础配置与提供方无关 -Status: rejected — 已由[将示例应用提取到 packages 中](../../implemented/architecture/2026-06-20-extract-example-app-packages.md)取代;后者把 spine 移入 `dsh-agent-spine-demo` bundle 并删除 `base*.yml` 文件,因此已不存在可重命名的共享基础 YAML。 +Status: rejected — 已由[将示例应用提取到 packages 中](../../implemented/architecture/2026-06-20-extract-example-app-packages.md)取代;后者把主干移入 `dsh-agent-spine-demo` bundle 并删除 `base*.yml` 文件,因此已不存在可重命名的共享基础 YAML。 [English](2026-06-20-providerless-example-base.md) | 中文 @@ -12,9 +12,9 @@ Status: rejected — 已由[将示例应用提取到 packages 中](../../impleme ## 提案 -将与提供方无关的核心重命名为 `examples/base.yml`,让适配器选择在每个具体示例中显式声明。编码和 ACP 真实配置添加一小段 `llm-deepseek` include 或本地块;快照配置添加 `llm-replay`。删除 `examples/base-core.yml`。 +将与提供方无关的核心重命名为 `examples/base.yml`,让适配器选择在每个具体示例中显式声明。编码和 ACP(Agent Client Protocol)真实配置添加一小段 `llm-deepseek` include 或本地块;快照配置添加 `llm-replay`。删除 `examples/base-core.yml`。 -共享基础应仅包含提供方无关的服务与工具:`llm`、会话、系统提示词、工具、agent、不变式、bash 执行器和 bash 工具 schema。任何涉及模型提供方选择的内容都应放在叶子配置中。 +共享基础应仅包含提供方无关的服务与工具:`llm`、会话、系统提示词、工具、agent(智能体)、不变式、bash 执行器和 bash 工具 schema。任何涉及模型提供方选择的内容都应放在叶子配置中。 ## 验收标准 @@ -22,7 +22,7 @@ Status: rejected — 已由[将示例应用提取到 packages 中](../../impleme - `examples/base-core.yml` 已删除。 - 真实演示配置显式添加 DeepSeek 适配器。 - 快照回放配置 include 同一个与提供方无关的基础,并加入其回放适配器。 -- [examples README](../../../../examples/README.md)、各示例 README 及 Agent Note 引用不再解释「base = base-core 加适配器」。 +- [examples README](../../../../examples/README.md)、各示例 README 及 Agent Note(agent 决策记录)引用不再解释「base = base-core 加适配器」。 ## 放弃了什么 diff --git a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml index 0ea6f9f843..449f33cbff 100644 --- a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml +++ b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-generate-agent-note-index-tables.md: 6e5221f018942a0629f30b6e6f22cedfb9f4145e -2026-07-04-generate-agent-note-index-tables.zh.md: 572b6c743b661075f37e8602a81e0ceb800921a5 +2026-07-04-generate-agent-note-index-tables.zh.md: f8ebcd51933b3ad91e0197fc71c0d8aae568bbcf diff --git a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md index 572b6c743b..f8ebcd5193 100644 --- a/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md +++ b/.agents/notes/rejected/process/2026-07-04-generate-agent-note-index-tables.zh.md @@ -25,7 +25,7 @@ README.md 中由标记分隔的表格会混合生成内容与策展文本,因 ### 为什么不采用纯校验器模式? -它能捕获错误,但每次提案编辑仍然要在手工维护的表格中触碰共享热点。作者已经命名并放置了文件,因此索引副本不增加任何信息。这与[包清单提案](../../proposed/process/2026-06-20-discover-package-inventory.md)对 tsconfig 引用和 knip 配置段所做的手写列表与推导之间的判断相同。 +它能捕获错误,但每次提案编辑仍然要在手工维护的表格中触碰共享热点。作者已经命名并放置了文件,因此索引副本不增加任何信息。这与[包(package)清单提案](../../proposed/process/2026-06-20-discover-package-inventory.md)对 tsconfig 引用和 knip 配置段所做的手写列表与推导之间的判断相同。 ## 后果 diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml index 034b4df2e0..6685685e24 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-assembled-assistant-messages-only.md: ba8135a3d63f292cfedd23de8b4b9d43b4455e8c -2026-06-20-assembled-assistant-messages-only.zh.md: ef24a2eafbdeb71c7637f52804892abaa8e80fa6 +2026-06-20-assembled-assistant-messages-only.zh.md: 9a42a202425158edd85d7a3f2ef4b0b97e00da90 diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md index ef24a2eafb..9a42a20242 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md @@ -1,6 +1,6 @@ # Agent Note: 仅持久化组装后的 assistant 消息,不存储流式分片 -Status: rejected — 高保真 chunk 重放、部分失败流与快照重放目前依赖持久化的 `assistant/chunk` 事件。只有具备不丢失信息的重放/artifact 替代方案后,才能删除 chunk。 +Status: rejected — 高保真分片回放、部分失败流与快照回放目前依赖持久化的 `assistant/chunk` 事件。只有具备不丢失信息的回放/产物替代方案后,才能删除分片。 [English](2026-06-20-assembled-assistant-messages-only.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml index 174d0802cd..d06734c3ea 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-acp-session-load.md: 2b6edf173506929f2d64e910b47aa70fb4f1d854 -2026-06-20-drop-acp-session-load.zh.md: c63ac09903ecfa1aaad44d41e70e452bdcb17bf3 +2026-06-20-drop-acp-session-load.zh.md: 8e03acad17cf2831a15d5992a0419a73f275719d diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md index c63ac09903..8e03acad17 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 移除 ACP session/load,直到恢复具备产品形态 +# Agent Note: 移除 ACP(Agent Client Protocol)session/load,直到恢复具备产品形态 Status: rejected — Zed 是当前目标 ACP 客户端,它声明并实际使用支持加载的会话,还为并发的 `session/load` 保留待加载状态。桥接层应保留 `session/load` 并巩固恢复契约。 @@ -6,7 +6,7 @@ Status: rejected — Zed 是当前目标 ACP 客户端,它声明并实际使 ## 问题 -ACP(Agent Client Protocol)声明 `loadSession: true` 并实现 `session/load`:向 bridge 注入持久化能力、校验 cwd 与存储元数据的一致性、从持久化日志重建 agent(智能体),并向客户端回放先前的 transcript(文本记录)更新。该路径有自己的竞态处理、loading-id 守卫、回放展示逻辑和测试。它还依赖规范日志保留足够的 UI 数据,以重建旧的分片和工具展示。 +ACP 声明 `loadSession: true` 并实现 `session/load`:向 bridge 注入持久化能力、校验 cwd 与存储元数据的一致性、从持久化日志重建 agent(智能体),并向客户端回放先前的 transcript(文本记录)更新。该路径有自己的竞态处理、loading-id 守卫、回放展示逻辑和测试。它还依赖规范日志保留足够的 UI 数据,以重建旧的分片和工具展示。 持久化仍然是基础能力,但编辑器可见的恢复尚未经过产品流程设计。目前没有会话选择器、没有标题/预览元数据,也没有明确的加载失败或部分加载的用户体验。bridge 正在为一个仅被测试、文档和当前目标客户端的会话模型所使用的功能付出复杂度代价。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index f3b457a8b2..45949d3fcf 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-acp-terminal-meta.md: 79da387ac1a7a0e6767e3bf24baa6039e39ef90d -2026-06-20-drop-acp-terminal-meta.zh.md: e34cd0690beed5d3a1715db6bc3e8413e6227440 +2026-06-20-drop-acp-terminal-meta.zh.md: d29fd54618611f56fd071a0ee4a63bc207895d89 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index e34cd0690b..d29fd54618 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 移除 ACP 终端 `_meta` 渲染 +# Agent Note: 移除 ACP(Agent Client Protocol)终端 `_meta` 渲染 Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是有意设计的 Zed UX,同时为其他客户端保留普通 ACP 回退。 @@ -6,7 +6,7 @@ Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是 ## 问题 -ACP(Agent Client Protocol)桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 Agent Note(agent 决策记录)](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md)刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 +ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 Agent Note(agent 决策记录)](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md)刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能特性,而非推测性装饰。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml index 514662e600..e09db8fbbf 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-bash-output-spill-files.md: b2bd1a04ee1524bab29814ffa7c22712a83ee5f7 -2026-06-20-drop-bash-output-spill-files.zh.md: 4a2a8e7d3b0477c0a221e9fe6abe879cfa513aeb +2026-06-20-drop-bash-output-spill-files.zh.md: c1b5670fac28a90cc4eb229ba0013e39067af8eb diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md index 4a2a8e7d3b..c1b5670fac 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除 bash 完整输出溢出文件 -Status: rejected — 完整输出恢复是真实的 bash 行为。未来的 artifact/blob 服务或许能将其泛化,但在替代方案就位前删除溢出文件会丢失有用的命令输出。 +Status: rejected — 完整输出恢复是真实的 bash 行为。未来的产物/blob 服务或许能将其泛化,但在替代方案就位前删除溢出文件会丢失有用的命令输出。 [English](2026-06-20-drop-bash-output-spill-files.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml index e9a21021b1..698d5a5ad6 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-durable-step-boundaries.md: c5c4f269a378e334c4dc509d1288146d77d9a520 -2026-06-20-drop-durable-step-boundaries.zh.md: 17d5b6d1860fd256758cadb429de0f390089826e +2026-06-20-drop-durable-step-boundaries.zh.md: f2150699c74b16557d936d6833fcba02e7d76e69 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md index 17d5b6d186..f2150699c7 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除持久化的步骤边界事件 -Status: rejected — `step/end` 是模型步骤已完成的持久信号;保留对称的 `step/start` / `step/end` 对,比从相邻的步骤作用域事件推断完成状态更便于理解崩溃修复、不变式与 transcript 检查。 +Status: rejected — `step/end` 是模型步骤已完成的持久信号;保留对称的 `step/start` / `step/end` 对,比从相邻的步骤作用域事件推断完成状态更便于理解崩溃修复、不变式与 transcript(文本记录)检查。 [English](2026-06-20-drop-durable-step-boundaries.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml index a5b3ffc665..a9913a8849 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-drop-unused-session-lineage.md: 605f1949999435b24404e0c5a72320416303ae52 -2026-06-20-drop-unused-session-lineage.zh.md: f49e9af0fe22be41228b058f5c700815cc7aa893 +2026-06-20-drop-unused-session-lineage.zh.md: 981f44189f4b8f11f513261db7094afdc650dffa diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md index f49e9af0fe..981f44189f 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md @@ -1,6 +1,6 @@ # Agent Note: 移除未使用的会话血缘元数据 -Status: rejected — `parentSession` 是已记录的 fork/subagent seam 的一部分,并已由 agent/session 恢复路径保留。该字段面向未来,但并非意外遗留的死状态。 +Status: rejected — `parentSession` 是已记录的 fork/subagent seam 的一部分,并已由 agent(智能体)/会话恢复路径保留。该字段面向未来,但并非意外遗留的死状态。 [English](2026-06-20-drop-unused-session-lineage.md) | 中文 @@ -19,7 +19,7 @@ Status: rejected — `parentSession` 是已记录的 fork/subagent seam 的一 ## 验收标准 - `SessionHeader` 仅包含 version、id、createdAt 和可选的 cwd。 -- JSONL 与 SQLite 元数据 schema 不再存储 parent-session id。 +- JSONL 与 SQLite 元数据 schema 不再存储父会话 id。 - 恢复与列表 API 不再往返传递 `parentSession`。 - 文档和测试移除没有生产消费方支撑的 fork 血缘声明。 - 会话格式版本、后端 schema 版本与记录的 fixture(测试前置数据)按需刷新;按预发布格式策略,非当前版本的存储数据将被拒绝,不提供迁移路径。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml index 76c31d49a5..5f3e15ee98 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-fold-session-persistence-interface.md: 895b868b2a80d8655284bae1364a85e19e174da7 -2026-06-20-fold-session-persistence-interface.zh.md: 8b1d87431b27d91c9b9765055d783dea940b981d +2026-06-20-fold-session-persistence-interface.zh.md: c124b16531f904eb72cb8ac3842642d819309e14 diff --git a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md index 8b1d87431b..c124b16531 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md @@ -12,16 +12,16 @@ Status: rejected — 独立的持久化接口包是为持久后端设计的模 ## 提案 -将抽象的 `SessionPersistence` 服务、协调器和持久化契约辅助工具移入 `dsh-session`。JSONL 和 SQLite 仍作为独立的后端包,注册由 session 包拥有的服务。这样既保留了后端可替换性,又删除了一个支撑包和一条跨包 seam。 +将抽象的 `SessionPersistence` 服务、协调器和持久化契约辅助工具移入 `dsh-session`。JSONL 和 SQLite 仍作为独立的后端包,注册由会话包拥有的服务。这样既保留了后端可替换性,又删除了一个支撑包和一条跨包 seam。 -实施 PR(Pull Request)应更新[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md) 指南,补充此例外:持久化不同于 bash 或 LLM(大语言模型),因为它的词汇和生命周期事件本就属于 session 包的核心领域。 +实施 PR(Pull Request)应更新[能力 seam](../../implemented/architecture/2026-06-13-capability-seams.md) 指南,补充此例外:持久化不同于 bash 或 LLM(大语言模型),因为它的词汇和生命周期事件本就属于会话包的核心领域。 ## 验收标准 - `@deepseek-ai/dsh-session-persistence` 作为包被移除。 - `dsh-session` 导出持久化服务类型、协调器和契约辅助工具。 - JSONL 和 SQLite 后端包直接依赖 `dsh-session`。 -- `agent-loop` 的恢复功能使用 session 包拥有的服务键。 +- `agent-loop` 的恢复功能使用会话包拥有的服务键。 - [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)、[共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)与[包文档](../../../../packages/session-persistence/session-persistence/README.md)说明后端实现为何仍保持独立。 ## 放弃了什么 diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml index a95113afcc..da93063670 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generic-tool-rendering.md: 6fc610546da04e7d1e16fc17ada87483a142aa3c -2026-06-20-generic-tool-rendering.zh.md: 4310e35a71fb13dfe1b00190da444a56bc8b4e81 +2026-06-20-generic-tool-rendering.zh.md: 11386b87d845129950a8473eb1cf4ea6ce697ac8 diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md index 4310e35a71..11386b87d8 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -1,14 +1,14 @@ # Agent Note: 收拢工具自有的 UI 展示逻辑 -Status: rejected — 工具拥有的呈现机制应等到出现更多真实工具后再进行泛化或删除。Bash 与 ACP 目前仍需要现有的丰富呈现路径。 +Status: rejected — 工具拥有的呈现机制应等到出现更多真实工具后再进行泛化或删除。Bash 与 ACP(Agent Client Protocol)目前仍需要现有的丰富呈现路径。 [English](2026-06-20-generic-tool-rendering.md) | 中文 ## 问题 -工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步增长为一堆可选字段。ACP(Agent Client Protocol)随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 +工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步增长为一堆可选字段。ACP 随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 -真正的第一方用途是为 ACP 提供 bash 展示。这不足以作为冻结一个跨包 UI 展示 API 的依据。 +真正的第一方用途是为 ACP 提供 bash 展示。这不足以作为冻结一个跨包(package)UI 展示 API 的依据。 ## 提案 diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml index dc925b0bbc..3ca6a50ef7 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-retire-mid-turn-steering.md: a8812b3222739244d77f4d4dab60cf7c0cd6907d -2026-06-20-retire-mid-turn-steering.zh.md: f31a062e73043348183c27053e73f79168ba8cef +2026-06-20-retire-mid-turn-steering.zh.md: 81a211a167daeb8c57b98f2a1c1451dbc54d09e4 diff --git a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md index f31a062e73..81a211a167 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md @@ -1,14 +1,14 @@ # Agent Note: 移除轮次中途引导 -Status: rejected — 轮次中途 steering 是一项有意设计的 agent 能力,用于接收步骤之间的用户/插件输入以及未来的 goal/loop 工作流。它是面向产品方向的复杂度,而非 `send()` 的意外重复。 +Status: rejected — 轮次中途 steering(中途引导)是一项有意设计的 agent(智能体)能力,用于接收步骤之间的用户/插件输入以及未来的 goal/loop 工作流。它是面向产品方向的复杂度,而非 `send()` 的意外重复。 [English](2026-06-20-retire-mid-turn-steering.md) | 中文 ## 问题 -agent(智能体)暴露了两条用户消息路径,外观相近但生命周期语义不同:`send()` 将一条普通用户轮次排入队列,而 `steer()` 在当前运行轮次的步骤之间注入一条消息,空闲时则回退为 `send()`。这一区分贯穿整个栈:`Agent.steer()` 是公开 API;会话日志有持久化的 `steering/message` 事件;agent 事件分类体系有 `agent/steering`;agent loop(智能体循环)在排队消息 FIFO 之外还维护一个 steering FIFO;取消操作需要清空两个队列;`deriveMessages()` 必须将 steering 渲染为带标签的合成用户消息,而非普通提示词。 +agent 暴露了两条用户消息路径,外观相近但生命周期语义不同:`send()` 将一条普通用户轮次排入队列,而 `steer()` 在当前运行轮次的步骤之间注入一条消息,空闲时则回退为 `send()`。这一区分贯穿整个栈:`Agent.steer()` 是公开 API;会话日志有持久化的 `steering/message` 事件;agent 事件分类体系有 `agent/steering`;agent loop(智能体循环)在排队消息 FIFO 之外还维护一个 steering FIFO;取消操作需要清空两个队列;`deriveMessages()` 必须将 steering 渲染为带标签的合成用户消息,而非普通提示词。 -续行 seam 进一步放大了成本。`agent/turn-continuation` 默认条件为 `hadToolCalls || steeringInjected`,因此同一轮次内的 steering(中途引导)消息即使模型未请求工具调用,也会强制循环再次调用模型。注释中提到了未来 `/goal`、`/loop` 和预算守卫的用途,但当前仓库没有生产级监听器;只有测试注册了该 waterfall(瀑布式事件)。另外,唯一调用 `steer()` 的生产 UI 是 stdio 演示。ACP(Agent Client Protocol)在轮次运行期间已经通过普通队列发送提示词。 +续行 seam 进一步放大了成本。`agent/turn-continuation` 默认条件为 `hadToolCalls || steeringInjected`,因此同一轮次内的 steering 消息即使模型未请求工具调用,也会强制循环再次调用模型。注释中提到了未来 `/goal`、`/loop` 和预算守卫的用途,但当前仓库没有生产级监听器;只有测试注册了该 waterfall(瀑布式事件)。另外,唯一调用 `steer()` 的生产 UI 是 stdio 演示。ACP(Agent Client Protocol)在轮次运行期间已经通过普通队列发送提示词。 ## 提案 diff --git a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml index 550a17a6b1..66138d0995 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-single-session-acp-bridge.md: e99de76854390a0979d1b66866d1d48aacbc0036 -2026-06-20-single-session-acp-bridge.zh.md: e4c9a4c9371749f02a4a7c4f55d724ba4641e2c0 +2026-06-20-single-session-acp-bridge.zh.md: 660e4ccf6f2fba8672315bfed30872870f401554 diff --git a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md index e4c9a4c937..660e4ccf6f 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将 ACP 桥接恢复为每连接一个活跃会话 +# Agent Note: 将 ACP(Agent Client Protocol)桥接恢复为每连接一个活跃会话 Status: rejected — Zed 是当前目标 ACP 客户端,其 ACP 实现明确支持多会话:它把活跃会话存入 `HashMap<SessionId, AcpSession>`,跟踪 `pending_sessions`,合并同一 id 的并发加载,并测试加载期间关闭的行为。 @@ -6,7 +6,7 @@ Status: rejected — Zed 是当前目标 ACP 客户端,其 ACP 实现明确支 ## 问题 -ACP(Agent Client Protocol)桥接现在支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向会话/agent(智能体)查找、逐会话的提示词状态、加载中 id、每条事件的解复用、跨会话拆除,以及未来权限提示与后台任务的隔离问题。较早的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在追踪未完成的权限归属部分;本 Agent Note(agent 决策记录)是与之竞争的简化路径。 +ACP 桥接现在支持在一条 JSON-RPC 连接上承载多个活跃会话。这一能力带来了多条目会话映射、反向会话/agent(智能体)查找、逐会话的提示词状态、加载中 id、每条事件的解复用、跨会话拆除,以及未来权限提示与后台任务的隔离问题。较早的[多会话 ACP 提案](../../implemented/feature/2026-06-14-acp-multi-session.md)仍在追踪未完成的权限归属部分;本 Agent Note(agent 决策记录)是与之竞争的简化路径。 产品目标已经证明它需要在一个 harness 进程上承载并发的编辑器对话:Zed 的 ACP 连接拥有多个会话和加载状态。快照回放层仍然避免并发模型流,因为其回放条目是位置相关的;这是测试 fixture(测试前置数据)的局限,而非移除桥接多路复用的理由。 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml index 68402cc8a7..9720d3c114 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-unimplemented-subagent-vocabulary.md: 890aca31f09f97ab6d9bf7c00f738d894695d9ad -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: f4525b609c1c1bab54a2063ceb1fa143ab75ec63 +2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 7837759604a30ee8f58d922bb5f55f6730d1ddcb diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md index f4525b609c..7837759604 100644 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md @@ -6,12 +6,12 @@ Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`tool ## 问题 -[subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:启动时由服务检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三个启动时特性和两个可选运行时方法的实现数与调用数均为零: +[subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:启动时由服务检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三个启动时功能和两个可选运行时方法的实现数与调用数均为零: - **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):在作出决策时,每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅出现在脚本化测试 fixture(测试前置数据)中。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 - **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——包括 mock 也没有;spawn spec 断言的正是它们的*缺失*。 -在作出决策时,`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 schema 类型(现为 `ObjectJsonSchema`)。三个后续 subagent 工作流(per-session 快照回放、fork seed 边界、ACP(Agent Client Protocol) 后端)都围绕这块接口面落地,却没有增长出哪怕一个消费方。 +在作出决策时,`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 schema 类型(现为 `ObjectJsonSchema`)。三个后续 subagent 工作流(按会话快照回放、fork seed 边界、ACP(Agent Client Protocol)后端)都围绕这块接口面落地,却没有增长出哪怕一个消费方。 ## 提案 @@ -19,7 +19,7 @@ Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`tool **保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个工具默认值,而非删除正在工作的强制逻辑。 -审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md)恰好记录了这种形态从 bash executor 中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent 观测/丰富化 Agent Note](../../implemented/feature/2026-06-30-subagent-observe-enrich.md)的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 +审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md)恰好记录了这种形态从 bash 执行器中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent 观测/丰富化 Agent Note](../../implemented/feature/2026-06-30-subagent-observe-enrich.md)的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 这是[从持久化 seam 裁剪死方法](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须为无人声明的成员,甚至更弱,因为这里连一个实现都没有。 @@ -36,4 +36,4 @@ Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`tool ## 风险 -subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 Agent Note 缩减的 seam 词汇范围内;observe-enrich Agent Note 记录了因缺少消费方而删除 `agentType` 兄弟字段的判断,本 Agent Note 延续了这一判断。CC hooks 桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不涉及本文移除的任何接口面;observe-enrich Agent Note 推迟的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 Agent Note 模式所预期的重新添加触发点。 +subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 Agent Note 缩减的 seam 词汇范围内;observe-enrich Agent Note 记录了因缺少消费方而删除 `agentType` 兄弟字段的判断,本 Agent Note 延续了这一判断。CC 钩子桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不涉及本文移除的任何接口面;observe-enrich Agent Note 推迟的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 Agent Note 模式所预期的重新添加触发点。 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 53028cf3a3..749ac25ddb 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-collapse-workflow-to-foreground-core.md: 629e2140523c3ae7caf533de99821206d05f1b8e -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 79eb61653e812647082ba6ebcb7f0636db6e0240 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: f71ea19f563ff64a1f638c644d35f51148e493cb diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 79eb61653e..f71ea19f56 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -8,7 +8,7 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 工作流能力执行前台 JavaScript 来编排 subagent,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体) outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 -这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent(智能体)、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 +这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 @@ -20,11 +20,11 @@ live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.i 保留已使用的核心:`agent(prompt, { schema, model })`、`parallel`、`pipeline`、`args`、并发/agent 上限、取消、有界 dispose(资源释放)、结构化结果、worker 隔离与前台工具收集。移除所有 `workflow/*` 事件及其仅供事件使用的 info/outcome 类型;移除 `phase()`、`log()`、agent 的 `label`/`phase`、phase 声明、`whenToUse` 及其 worker 消息/host 观测者;将工作流元数据收缩为工具实际使用的 name;移除仅供事件使用的 run id/meta 快照与合成的 agent-end 账本。将 `WorkflowRun` 收缩为 `result`、`cancel()` 和 `dispose()`;工具渲染请求方持有的 name。移除 `WorkflowStartRequest.signal` 及 worker host 的 input-signal listener/disarm 状态,保留调用方从其 abort signal 到 `run.cancel()` 的桥接。将 `WorkflowError` 变为单一的 fatal 错误类,不再有布尔模式或 `isFatalWorkflowError()` 辅助函数。 -修订已实施的 dynamic-workflow Agent Note(agent 决策记录),并更新 seam/工具/worker README、工具 schema、生成的 catalog 与包(package)依赖图、worker type-equiv 记录、单元测试以及工作流快照/header fixture(测试前置数据)。如果进度 UI 工作被立项,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活这套协议。 +修订已实施的动态工作流 Agent Note(agent 决策记录),并更新 seam/工具/worker README、工具 schema、生成的 catalog 与包(package)依赖图、worker type-equiv 记录、单元测试以及工作流快照/header fixture(测试前置数据)。如果进度 UI 工作被立项,应从一份命名了父 agent/会话/工具调用的关联契约出发,而非原样复活这套协议。 ## 曾考虑的替代方案 -**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的 dynamic-workflow 元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 +**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 ## 验收标准 diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml index c28461d08b..3b97a7fb1e 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-prune-unused-skill-registry-surface.md: 5a13effa04a6cd9954741a0a33ebc6fc3512fab8 -2026-07-12-prune-unused-skill-registry-surface.zh.md: 6d917fadf3d408c934e88b2bd0a9c7691632f91b +2026-07-12-prune-unused-skill-registry-surface.zh.md: 46d49a02c294c492abdd6e7e611a5c92eb317c12 diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md index 6d917fadf3..46d49a02c2 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 裁剪 skill 注册表中未使用的接口 +# Agent Note: 裁剪 skill(技能)注册表中未使用的接口 Status: rejected — 直接在运行时注册 skill 是为第三方插件保留的有意扩展路径。 @@ -6,7 +6,7 @@ Status: rejected — 直接在运行时注册 skill 是为第三方插件保留 ## 问题 -skill(技能)服务的嵌入式运行时子系统中,`ctx.skills.register()` 没有任何生产调用方。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose(资源释放)器以及相应测试——而所有已交付的 skill 都只使用提供方 seam。`SkillSummary.whenToUse` 和 candidate/definition 的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位器。有意开放的 `metadata` 扩展点保留不动。 +skill 服务的嵌入式运行时子系统中,`ctx.skills.register()` 没有任何生产调用方。它引入了一个保留的 `runtime` 提供方名称、一套运行时 map/rank/source、重复策略、缓存键中的第二个 revision、规范化逻辑、dispose(资源释放)器以及相应测试——而所有已交付的 skill 都只使用提供方 seam。`SkillSummary.whenToUse` 和 candidate/definition 的 `path` 被解析和复制,但没有任何生产消费方读取它们:模型目录只渲染 name/description,资源加载使用 `resourceBase`,提供方自行管理其定位器。有意开放的 `metadata` 扩展点保留不动。 ## 提案 From 9848a9542be94fd5ae4692c17dbd903fd00f7191 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:02:53 +0800 Subject: [PATCH 076/113] docs: restore architecture headroom --- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3eec5bed79..1125393ba3 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 02b5d854f87e06db6b90d6402ea630e196bc5a23 -architecture.zh.md: 57adb6a2959ee9336364b403f736f914b72b42f4 +architecture.md: 937d0e5dc2d41140506ecb9483e1b2d35abcb41f +architecture.zh.md: 255035c4018b9a4edc788441225d47cb43757c76 diff --git a/docs/architecture.md b/docs/architecture.md index 02b5d854f8..937d0e5dc2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ Session events are turn-enclosed; reload closes an interrupted tail with a synth ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins normally use intent-named `send()`, `queue()`, `steer()`, and `inject()`; callers with fully resolved routing may use `acceptInput()` with every field mandatory ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control cancellation and quiescence. Caller, provider, and handle co-own one awaited teardown. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `queue()`, `steer()`, and `inject()`; callers may use mandatory-field `acceptInput()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 57adb6a295..255035c401 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,7 +131,7 @@ forever: ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件通常使用按意图命名的 `send()`、`queue()`、`steer()` 和 `inject()`;调用方若已持有完全解析的路由信息,可以使用 `acceptInput()`,其中每个字段均为必填项([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制取消和停稳过程。调用方、提供方和句柄共同拥有一项需等待完成的拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`queue()`、`steer()` 和 `inject()`;调用方可以使用各字段均为必填项的 `acceptInput()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 ### Agent 作用域 From aeb8b1f486f457bc44081667e82e5c9bb5b67606 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 14:09:00 +0800 Subject: [PATCH 077/113] feat: implement new session behavior to clear selection and show empty state - Added bilingual notes for the new session feature, detailing the transition to an empty state upon session creation. - Updated `SessionsService` to include a `clear()` method that resets the current selection and persists the empty state. - Enhanced the `EmptyState` component to reflect the new design, including workspace selection and input handling. - Modified CSS styles for improved layout and visual consistency in the empty state. - Updated tests to cover the new session clearing functionality and its effects on the UI. --- ...ew-session-clears-to-empty-state.i18n.yaml | 6 + ...07-24-new-session-clears-to-empty-state.md | 23 +++ ...24-new-session-clears-to-empty-state.zh.md | 23 +++ .../runtime/src/client/sessions/service.ts | 20 +- .../runtime/tests/sessions-service.spec.ts | 20 ++ .../src/client/skeleton/EmptyState.module.css | 105 ++++++++--- .../src/client/skeleton/EmptyState.tsx | 172 ++++++++++++------ .../src/client/skeleton/InputBar.module.css | 76 +++++++- .../src/client/skeleton/InputBar.tsx | 109 ++++++++--- .../ui-conversation/tests/input-bar.spec.tsx | 42 ++++- .../tests/skeleton-branches.spec.tsx | 15 +- .../ui-conversation/tests/skeleton.spec.tsx | 22 ++- packages/client/ui-sidebar/README.md | 2 +- .../ui-sidebar/src/client/contract/slots.ts | 4 +- .../client/ui-sidebar/src/client/index.ts | 12 +- .../client/ui-sidebar/tests/apply.spec.tsx | 16 +- 16 files changed, 532 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml new file mode 100644 index 0000000000..5f41ac4b70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db +2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895 diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md new file mode 100644 index 0000000000..d730f3e0b6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -0,0 +1,23 @@ +# Agent Note: New Session clears onto the empty-state launch + +Status: implemented + +English | [中文](2026-07-24-new-session-clears-to-empty-state.zh.md) + +## Problem + +Sidebar "New Session" created and opened a blank session immediately, so the center column showed `ConversationRoot` with an empty transcript and the resident composer. The Figma NEW SESSION screen (`EmptyState` + shared `InputBar` hero) only rendered when `sessions.current` was already undefined, so the launch page was unreachable from the primary creation control. + +## Decision + +`SessionsService.clear()` wipes the persisted selection and `list.current`. Top-level sidebar creation entries (`onCreate()` with no cwd — New Session and New Workspace) call `clear()` so `AppFrame` renders `conversation.empty`. The empty state's first send still runs `conversation.startSession` (create → open → send) and reuses the same `InputBar` component as the resident composer (`variant="hero"`). Per-project "+" (`onCreate(cwd)`) keeps create-then-open until the empty-state picker can accept a seeded cwd. + +## Alternatives considered + +**Keep create-then-open for New Session and add a second empty chrome inside ConversationRoot when the transcript is empty.** Rejected: that duplicates the launch InputBar and breaks the empty→content ruling that one InputBar moves position rather than swapping components. + +**Route New Session through a dedicated route or slot outside selection.** Rejected for this pass: `conversation.empty` already owns the launch UI; clearing `current` is the existing empty branch. + +## Consequences + +New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `<select>` state only — host plan, access, and model seams remain unwired. diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md new file mode 100644 index 0000000000..602a2b774b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md @@ -0,0 +1,23 @@ +# Agent Note: New Session clears onto the empty-state launch + +Status: implemented + +[English](2026-07-24-new-session-clears-to-empty-state.md) | 中文 + +## Problem + +侧栏「New Session」会立即创建并打开空白会话,因此中间栏显示带空 transcript(文本记录)与常驻 composer 的 `ConversationRoot`。Figma 的 NEW SESSION 屏(`EmptyState` + 共用的 `InputBar` hero)仅在 `sessions.current` 已为 undefined 时渲染,因而主创建控件无法到达启动页。 + +## Decision + +`SessionsService.clear()` 清除持久化选中项与 `list.current`。顶层侧栏创建入口(无 cwd 的 `onCreate()`——New Session 与 New Workspace)调用 `clear()`,使 `AppFrame` 渲染 `conversation.empty`。空态的首次发送仍走 `conversation.startSession`(create → open → send),并复用与常驻 composer 相同的 `InputBar` 组件(`variant="hero"`)。按项目的「+」(`onCreate(cwd)`)继续 create-then-open,直到空态选择器能接受预填的 cwd。 + +## Alternatives considered + +**为 New Session 保留 create-then-open,并在 transcript 为空时于 ConversationRoot 内再加一套空态 chrome。** 否决:这会重复启动页的 InputBar,并破坏 empty→content 的约定——同一 InputBar 应移动位置,而非互换组件。 + +**将 New Session 路由到选中状态之外的专用 route 或 slot。** 本轮否决:`conversation.empty` 已拥有启动 UI;清除 `current` 即是既有的空态分支。 + +## Consequences + +New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区:鱼标 + 标题、卡片上方的 Menu 工作区 chip(「New Workspace」/ 路径 basename / 自由输入路径),再接共用的 `InputBar`(`variant="hero"`);选择器与卡片背后居中铺一层柔光椭圆(figma 313:14109),宽度按卡片锁定为 `1051/776`,随卡片缩放。`InputBar` 绘制底栏 chrome(添加 / Plan / Read-only / 模型),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b9116971d0..324c574474 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -95,9 +95,10 @@ export class SessionsService { /** * Persisted selection cell (the durable half of `list.current`). Private on * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open}. Projection validates it against the live list - * instead of destructively pruning, so a selection survives transient list - * states (reconnect re-pull) and resurfaces when its session returns. + * SessionsService.open} / {@link SessionsService.clear}. Projection + * validates it against the live list instead of destructively pruning, so a + * selection survives transient list states (reconnect re-pull) and + * resurfaces when its session returns. */ private readonly selection: SnapshotStore<{ sessionId?: SessionId }> @@ -137,7 +138,7 @@ export class SessionsService { /** * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere (the sole selection write path). + * nowhere. * @param id - session id (must exist in the list store). */ open(id: SessionId): void { @@ -148,6 +149,17 @@ export class SessionsService { this.list.update((draft) => { draft.current = id }) } + /** + * Clear the current selection so the layout shows the no-session empty + * state. Wipes the persisted selection too — a reload stays on empty until + * the user opens or starts a session. Staging holds the previous occupant + * across the blank (same masked-gap rule as a transient list miss). + */ + clear(): void { + this.selection.set({}) + this.list.update((draft) => { draft.current = undefined }) + } + /** * Create a session on the host. * @param opts - creation options (project directory). diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 97f223548c..33cfce43bb 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -133,6 +133,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone }) + it('clear() blanks list.current and the persisted selection', async () => { + const storage = new Map<string, string>() + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + clear: () => { storage.clear() }, + }) + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + expect(storage.get('dsh.sessions.current')).toContain('s1') + b.svc.clear() + expect(b.svc.list.getSnapshot().current).toBeUndefined() + // Persisted wipe: a fresh service with the same storage stays on empty. + const again = bench() + await feedList(again, [{ id: 's1' }]) + expect(again.svc.list.getSnapshot().current).toBeUndefined() + }) + it('masks (not destroys) the selection while its session is off the list', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index fbe2f25c09..53e618b122 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -1,6 +1,6 @@ -/* NEW SESSION hero: headline over the shared InputBar card, centered in the - conversation column. The card is the same component as the composer — - only positioning lives here. */ +/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the + shared InputBar card. The input itself is InputBar — only stack geometry + lives here. */ .root { display: flex; @@ -11,16 +11,18 @@ padding: 24px; } -/* figma hero group 34:10409: headline block sits 36px above the input card. */ -.card { +/* Cap matches InputBar card width (776). Glow may paint past the sides. */ +.stack { display: flex; flex-direction: column; - gap: 36px; + align-items: stretch; + gap: 40px; width: 100%; max-width: 776px; + overflow: visible; } -/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */ .headline { display: flex; align-items: center; @@ -32,37 +34,98 @@ color: var(--dsw-alias-label-primary); } -/* figma 34:10412/10413: brand-blue vector. */ +/* figma fish fill rides business blue. */ .fish { flex: none; color: var(--dsw-alias-state-business-primary); } -.picker { +/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is + centered on this block so it stays under the picker + InputBar together. */ +.body { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + overflow: visible; +} + +/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ +.glow { + position: absolute; + left: 50%; + top: 50%; + z-index: 0; + width: calc(100% * 1051 / 776); + aspect-ratio: 1051 / 468; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.body > :not(.glow) { + position: relative; + z-index: 1; +} + +.workspaceRow { display: flex; align-items: center; min-width: 0; + /* Align with InputBar's left chrome (card pad 10 + attach). */ + padding-left: 10px; } -.select, -.customInput { - max-width: 320px; - padding: 4px 10px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-bg-base); - font-size: 13px; +/* Folder + "New Workspace" + chevron (figma workspace trigger). */ +.workspace { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 4px 0 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 14px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.workspace:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.folder { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.workspaceLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-caption); } .customInput { - width: 320px; + width: min(320px, 100%); + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 8px; outline: none; + background: var(--dsw-alias-bg-base); + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); } .customInput:focus { - /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ border-color: var(--dsw-alias-state-business-primary); - color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index 420ff7a622..edcf0cbad2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -1,21 +1,27 @@ -// EmptyState (figma NEW SESSION screen): centered hero card built around the -// SAME InputBar component the resident composer uses (the empty→content -// transition is one component changing position, never a swap). Project -// picker: cwd set derived in-component from the standard useSessions hook -// (subscription is the framework's, derivation is a pure function — design -// §6) plus a free-form new-directory input; submit runs the startSession -// chain (create → open → send) in one service call. +// EmptyState (figma NEW SESSION screen): centered hero — fish + title, +// workspace picker row, then the SAME InputBar the resident composer uses +// (empty→content is a position move, never a swap). Project picker: cwd set +// derived in-component from useSessions plus a free-form new-directory path; +// submit runs startSession (create → open → send). -import { useMemo, useState } from 'react' -import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' +import { useId, useMemo, useState } from 'react' +import { + FishLogo, + IconChevronDownOutline14, + IconFolderOpen16, + Menu, + type MenuItem, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { EmptyStateSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' -/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */ +/** Menu id for the free-form directory entry (not a filesystem path). */ const NEW_DIR = '::new-directory' +/** Menu id for the host default project directory (empty cwd on create). */ +const DEFAULT_DIR = '::default' /** Full props composed by reference from the contract (runtime share & injected share; no store). */ export type EmptyStateProps = EmptyStateSlotProps @@ -30,16 +36,26 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } +/** Basename for the workspace chip; empty → the design's "New Workspace" label. */ +function workspaceLabel(cwd: string): string { + if (cwd === '') return 'New Workspace' + const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() + return base !== undefined && base !== '' ? base : cwd +} + export function EmptyState({ useSessions, startSession }: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') - const [cwd, setCwd] = useState<string>('') + const [cwd, setCwd] = useState('') const [custom, setCustom] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) const [sending, setSending] = useState(false) const [error, setError] = useState<InputBarError | null>(null) + // Stable filter id so multiple EmptyState mounts do not collide in the DOM. + const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` const submit = (mode: 'queue' | 'steer'): void => { const text = draft.trim() @@ -58,61 +74,105 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } - const picker = ( - <div className={css.picker}> - {custom - ? ( - <input - className={css.customInput} - value={cwd} - autoFocus - placeholder="目录路径,如 /home/me/proj" - onChange={(e) => { setCwd(e.target.value) }} - /> - ) - : ( - <select - className={css.select} - value={cwd} + const items: MenuItem[] = [ + { id: DEFAULT_DIR, label: 'Default directory' }, + ...cwds.map(c => ({ id: c, label: c })), + { id: NEW_DIR, label: 'New directory…' }, + ] + const selectedId = custom ? NEW_DIR : cwd === '' ? DEFAULT_DIR : cwd + + const workspace = custom + ? ( + <input + className={css.customInput} + value={cwd} + autoFocus + aria-label="项目目录" + placeholder="Directory path, e.g. /home/me/proj" + onChange={(e) => { setCwd(e.target.value) }} + /> + ) + : ( + <Menu + open={menuOpen} + onClose={() => { setMenuOpen(false) }} + selectedId={selectedId} + items={items} + onSelect={(id) => { + if (id === NEW_DIR) { + setCustom(true) + setCwd('') + } else if (id === DEFAULT_DIR) { + setCustom(false) + setCwd('') + } else { + setCustom(false) + setCwd(id) + } + setMenuOpen(false) + }} + anchor={( + <button + type="button" + className={css.workspace} aria-label="项目目录" - onChange={(e) => { - if (e.target.value === NEW_DIR) { - setCustom(true) - setCwd('') - } else { - setCwd(e.target.value) - } - }} + aria-haspopup="menu" + aria-expanded={menuOpen} + onClick={() => { setMenuOpen(!menuOpen) }} > - <option value="">默认目录</option> - {cwds.map(c => <option key={c} value={c}>{c}</option>)} - <option value={NEW_DIR}>新目录…</option> - </select> + <IconFolderOpen16 className={css.folder} size={16} /> + <span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span> + <IconChevronDownOutline14 className={css.chevron} size={14} /> + </button> )} - </div> - ) + /> + ) return ( <div className={css.root}> - <div className={css.card}> + <div className={css.stack}> <div className={css.headline}> - {/* figma 34:10412: fish 34x25 leading the headline, gap 10. */} + {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} <FishLogo size={34} className={css.fish} /> Let's start building </div> - <InputBar - draft={draft} - running={false} - disabled={sending} - error={error} - variant="hero" - placeholder="Message to run task, plan and build" - accessory={picker} - onDraftChange={setDraft} - onSend={submit} - /* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */ - onStop={() => {}} - /> + <div className={css.body}> + {/* figma 313:14109: soft ellipse behind workspace + InputBar; width + tracks the card (1051/776) so blur scales in userSpace with it. */} + <svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true"> + <defs> + <filter + id={glowFilterId} + x="0" + y="0" + width="1051" + height="468" + filterUnits="userSpaceOnUse" + colorInterpolationFilters="sRGB" + > + <feFlood floodOpacity="0" result="BackgroundImageFix" /> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" /> + </filter> + </defs> + <g filter={`url(#${glowFilterId})`}> + <ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" /> + </g> + </svg> + <div className={css.workspaceRow}>{workspace}</div> + <InputBar + draft={draft} + running={false} + disabled={sending} + error={error} + variant="hero" + placeholder="Message to run task, plan and build, enter for / commands" + onDraftChange={setDraft} + onSend={submit} + /* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */ + onStop={() => {}} + /> + </div> </div> </div> ) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1139c3c9c1..04e4f661c5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -119,12 +119,81 @@ min-height: 84px; } -/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */ +/* Toolbar: attach + Plan + Read-only on the left; model + send on the right + (figma Input_Bottom chrome). */ .row { display: flex; align-items: center; - justify-content: flex-end; - padding: 0 10px 10px 12px; + justify-content: space-between; + gap: 12px; + padding: 0 10px 10px 10px; + min-width: 0; +} + +.tools, +.trailing { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.trailing { + flex: none; + gap: 8px; +} + +/* Attach circle (figma + control): 28px, selector fill, primary glyph. */ +.add { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: var(--dsw-specific-selector); + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.add:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + +.add:disabled { + opacity: 0.5; + cursor: default; +} + +/* Plan / Read-only / model — native <select>, chip-like closed chrome. */ +.select { + max-width: 220px; + height: 28px; + padding: 0 22px 0 6px; + border: none; + border-radius: 8px; + outline: none; + background-color: transparent; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3.5 5.25L7 8.75L10.5 5.25' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 4px center; + background-size: 14px 14px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 20px; + white-space: nowrap; + cursor: pointer; + appearance: none; +} + +.select:hover:not(:disabled) { + background-color: var(--dsw-alias-interactive-bg-hover); +} + +.select:disabled { + opacity: 0.5; + cursor: default; } /* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light / @@ -133,6 +202,7 @@ .primary { display: grid; place-items: center; + flex: none; width: 34px; height: 34px; border: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c5c8307ffc..f6454071b3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -4,10 +4,14 @@ // position move of this component, never a swap (layout ruling). Running // LOCKS the input: textarea disabled with the draft visible, stop is the only // action; the turn ending re-enables and refocuses. +// +// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now — +// local native <select> state, no host wiring. -import { useEffect, useRef } from 'react' -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' +import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './InputBar.module.css' /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ @@ -24,13 +28,33 @@ export interface InputBarProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' placeholder?: string - /** Optional leading accessory row content (the empty state mounts its cwd picker here). */ + /** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */ accessory?: ReactNode onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void } +interface SelectOption { + id: string + label: string +} + +const PLAN_OPTIONS: readonly SelectOption[] = [ + { id: 'plan', label: 'Plan' }, + { id: 'agent', label: 'Agent' }, +] + +const READONLY_OPTIONS: readonly SelectOption[] = [ + { id: 'readonly', label: 'Read-only' }, + { id: 'readwrite', label: 'Read-write' }, +] + +const MODEL_OPTIONS: readonly SelectOption[] = [ + { id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' }, + { id: 'v4-pro', label: 'DeepSeek-V4-Pro' }, +] + export function InputBar({ draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, }: InputBarProps) { @@ -48,6 +72,11 @@ export function InputBar({ }, 10) } + // Placeholder chrome: selection is local until plan/mode/model seams land. + const [planId, setPlanId] = useState('plan') + const [readonlyId, setReadonlyId] = useState('readonly') + const [modelId, setModelId] = useState('v4-pro-high') + // Locked while running: the browser drops keystrokes AND focus on a disabled // textarea — no sending mid-turn, stop or wait. const locked = disabled || running @@ -88,6 +117,25 @@ export function InputBar({ if (!empty && !disabled) onSend('queue') } + const renderSelect = ( + aria: string, + value: string, + options: readonly SelectOption[], + onPick: (id: string) => void, + ): ReactNode => ( + <select + className={css.select} + aria-label={aria} + value={value} + disabled={locked} + onChange={(e: ChangeEvent<HTMLSelectElement>) => { onPick(e.target.value) }} + > + {options.map(opt => ( + <option key={opt.id} value={opt.id}>{opt.label}</option> + ))} + </select> + ) + return ( <div className={clsx(css.root, variant === 'hero' && css.hero)}> {error !== null && ( @@ -116,25 +164,42 @@ export function InputBar({ <div aria-hidden className={css.mirror}>{`${draft}\n`}</div> </div> <div className={css.row}> - <button - type="button" - className={clsx(css.primary, running && css.stopping)} - aria-label={primaryLabel} - title={running ? '停止本轮' : '发送(Enter)'} - disabled={!running && (empty || disabled)} - onMouseDown={keepFocus} - onClick={onPrimary} - > - {running ? ( - <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden> - <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" /> - </svg> - ) : ( - <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden> - <path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" /> - </svg> - )} - </button> + <div className={css.tools}> + <button + type="button" + className={css.add} + aria-label="添加" + title="添加" + disabled={locked} + onMouseDown={keepFocus} + > + <IconPlusOutline16 size={14} /> + </button> + {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} + </div> + <div className={css.trailing}> + {renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} + <button + type="button" + className={clsx(css.primary, running && css.stopping)} + aria-label={primaryLabel} + title={running ? '停止本轮' : '发送(Enter)'} + disabled={!running && (empty || disabled)} + onMouseDown={keepFocus} + onClick={onPrimary} + > + {running ? ( + <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden> + <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" /> + </svg> + ) : ( + <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden> + <path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" /> + </svg> + )} + </button> + </div> </div> </div> </div> diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 660127946b..6bf58f7d59 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -19,7 +19,10 @@ function setup(over?: Partial<InputBarProps>) { } const view = render(<InputBar {...props} />) const textarea = view.container.querySelector('textarea')! - const button = view.container.querySelector('button')! + // aria-label (not role name): title also contains 发送/停止 and would double-match. + const button = view.container.querySelector<HTMLButtonElement>( + `button[aria-label="${over?.running === true ? '停止' : '发送'}"]`, + )! return { view, textarea, button, props } } @@ -97,7 +100,7 @@ describe('running lock and primary button', () => { const textarea = view.container.querySelector('textarea')! expect(document.activeElement).toBe(textarea) textarea.blur() - fireEvent.mouseDown(view.container.querySelector('button')!) + fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!) expect(document.activeElement).toBe(textarea) }) @@ -129,3 +132,38 @@ describe('error strip and variants', () => { expect(view.container.querySelector('[class*="hero"]')).not.toBeNull() }) }) + +describe('placeholder chrome', () => { + it('renders attach / Plan / Read-only / model controls', () => { + const { view } = setup() + expect(view.getByLabelText('添加')).toBeTruthy() + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan') + expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') + expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high') + }) + + it('native select change updates the selected option', () => { + const { view } = setup() + const plan = view.getByLabelText('Plan mode') as HTMLSelectElement + fireEvent.change(plan, { target: { value: 'agent' } }) + expect(plan.value).toBe('agent') + const access = view.getByLabelText('Access mode') as HTMLSelectElement + fireEvent.change(access, { target: { value: 'readwrite' } }) + expect(access.value).toBe('readwrite') + }) + + it('model select can drop the High option', () => { + const { view } = setup() + const model = view.getByLabelText('Model') as HTMLSelectElement + fireEvent.change(model, { target: { value: 'v4-pro' } }) + expect(model.value).toBe('v4-pro') + expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro') + }) + + it('running locks the chrome selects and attach control', () => { + const { view } = setup({ running: true }) + expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true) + expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true) + }) +}) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 10dba860c0..b93b9c0b72 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -261,7 +261,7 @@ describe('EmptyState branches', () => { await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) - it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => { + it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( <EmptyState @@ -272,12 +272,13 @@ describe('EmptyState branches', () => { startSession={startSession} />, ) - const select = view.container.querySelector('select')! - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/proj', '::new-directory']) - fireEvent.change(select, { target: { value: '/proj' } }) - expect((select as HTMLSelectElement).value).toBe('/proj') - fireEvent.change(select, { target: { value: '::new-directory' } }) + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/proj', 'New directory…']) + fireEvent.click(view.getByRole('menuitem', { name: '/proj' })) + expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' })) const custom = view.container.querySelector('input')! fireEvent.change(custom, { target: { value: '/typed/dir' } }) const textarea = view.container.querySelector('textarea')! diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 55cb46be2c..b2636bfd4b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -28,7 +28,8 @@ const sid = (s: string): SessionId => s as SessionId afterEach(cleanup) beforeEach(() => { - localStorage.clear() + // jsdom normally provides localStorage; some host Node builds surface it as undefined. + globalThis.localStorage?.clear() }) /** Minimal conversation snapshot slice the skeleton reads. */ @@ -95,11 +96,13 @@ describe('EmptyState', () => { const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej })) render(<EmptyState useSessions={useSessions} startSession={startSession} />) - const select = screen.getByRole('combobox', { name: '项目目录' }) - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/w/app', '/w/lib', '::new-directory']) - fireEvent.change(select, { target: { value: '/w/app' } }) - const box = screen.getByPlaceholderText('Message to run task, plan and build') + const trigger = screen.getByRole('button', { name: '项目目录' }) + fireEvent.click(trigger) + const menu = screen.getByRole('menu') + expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…']) + fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' })) + const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') fireEvent.change(box, { target: { value: '造一个轮子' } }) fireEvent.keyDown(box, { key: 'Enter' }) expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' }) @@ -110,11 +113,12 @@ describe('EmptyState', () => { expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') }) - it('new-directory option swaps the select for a free-form input', () => { + it('new-directory option swaps the chip for a free-form input', () => { const { useSessions } = fakeSessions([]) render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />) - fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } }) - const custom = screen.getByPlaceholderText(/目录路径/) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' })) + const custom = screen.getByPlaceholderText(/Directory path/) fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 7529bfe89c..c165455b1a 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 3012a760af..a5ce65ef59 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -25,8 +25,8 @@ export type SidebarRootInjected = { /** Open (switch to) a session. */ onOpen: (id: SessionId) => void /** - * Create a session and open it; cwd targets a project group (the - * sidebar's three creation entries all land in the new session). + * New-session affordance: no cwd clears selection onto the empty-state + * launch; a cwd create-then-opens a session in that project group. */ onCreate: (cwd?: string) => void /** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index fe799a864f..be757ca183 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -27,9 +27,15 @@ export function apply(ctx: ClientContext): void { // list snapshot); layout keeps only panel geometry. onOpen: (id) => { ctx.sessions.open(id) }, onCreate: (cwd) => { - // Create-then-open: the sidebar's three creation entries all land - // in the new session (empty-state first-send stays with ui-conversation). - void ctx.sessions.create(cwd === undefined ? {} : { cwd }) + // Top-level New Session / New Workspace: clear selection so AppFrame + // shows conversation.empty (EmptyState + shared InputBar). Per-project + // "+" still create-then-opens into that cwd until workspace seeding + // reaches the empty-state picker. + if (cwd === undefined) { + ctx.sessions.clear() + return + } + void ctx.sessions.create({ cwd }) .then((id: SessionId) => { ctx.sessions.open(id) }) }, onToggleSidebar: () => { ctx.layout.toggleSidebar() }, diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 6b6b4f9474..44fdae18f8 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -26,7 +26,12 @@ async function bench() { byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, current: undefined, }) - const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() } + const sessions = { + list, + create: vi.fn(async () => sid('minted')), + open: vi.fn(), + clear: vi.fn(), + } const layout = { toggleSidebar: vi.fn() } ctx.provide('sessions', sessions) ctx.provide('layout', layout) @@ -91,14 +96,15 @@ describe('apply', () => { expect(sessions.open).toHaveBeenCalledWith('a') injected.onCreate() - expect(sessions.create).toHaveBeenCalledWith({}) + expect(sessions.clear).toHaveBeenCalledOnce() + expect(sessions.create).not.toHaveBeenCalled() + + injected.onCreate('/proj') + expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) // create-then-open lands after the create promise resolves. await Promise.resolve() await Promise.resolve() expect(sessions.open).toHaveBeenCalledWith('minted') - - injected.onCreate('/proj') - expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) }) it('teardown unregisters the slot entry', async () => { From 6e4aa13632e52cac6e7003268a1b5f78aafdddf8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:21:15 +0800 Subject: [PATCH 078/113] docs(i18n): clarify Cordis and test wording --- docs/cordis-primer.i18n.yaml | 2 +- docs/cordis-primer.zh.md | 6 +++++- docs/testing.i18n.yaml | 2 +- docs/testing.zh.md | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 6baf73be1e..ef52bf6812 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write cordis-primer.md: ee65e6e702ecaeb506ce7334032c38e09c936cda -cordis-primer.zh.md: ceb9916f19dde35141405caee82e1c7794147193 +cordis-primer.zh.md: ee4f6864ba7864fc95b5eb8e31acbcaea6e99825 diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index ceb9916f19..ee4f6864ba 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -25,14 +25,18 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 分发模式是事件公开契约的一部分。新的 harness 事件通过 `@mode` 标签记录模式,以便生成的目录可以将声明与分发调用点做交叉校验。 +<a id="cordis-waterfall-semantics"></a> + ## Cordis Waterfall 语义 -`ctx.waterfall` 是环绕中间件。监听器接收 `(...args, next)`。调用 `next()` 将可能经过包装的结果委托给下一个服务;不调用 `next()` 直接返回则短路。值通过 `next()` 的返回值向下传播。 +`ctx.waterfall` 是环绕中间件。监听器接收 `(...args, next)`。调用 `next()` 会执行下游监听器;下游返回值通过 `next()` 返回当前包装层,可由该层包装后继续向外返回。不调用 `next()` 直接返回则短路。 协作式监听器通常修改一个共享的请求或决策对象,然后委托。监听器也可以选择完全替换结果,下游监听器将只看到替换后的结果。仅当监听器必须在普通注册之前运行时才使用 `prepend: true`。 对于单决策事件,短路是设计意图。策略监听器在拥有决策权时可以不调用 `next()` 直接返回,而仅做标注或观察的监听器则必须委托。 +<a id="loader-configuration"></a> + ## Loader 配置 `@cordisjs/plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 89e63f7fef..679b80380c 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write testing.md: 5a18397ba2431a4c4f2595d32d9de6fe3ddeb6f4 -testing.zh.md: 19ee4aa6abffc13c35b1933e2af0ed38eef5c7e6 +testing.zh.md: 9cb4bfa2c2d23e54fa0c90c0e901944dc1a64165 diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 19ee4aa6ab..9cb4bfa2c2 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -6,7 +6,7 @@ ## 层级 -- **单元测试**(`pnpm run test`):vitest 运行 `packages|examples/*/tests/**/*.spec.ts`,与被测代码同目录。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 +- **单元测试**(`pnpm run test`):vitest 运行 `packages|examples/*/tests/**/*.spec.ts`;测试与被测代码位于同一个包中,测试文件放在该包的 `tests/**` 下。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外呈现。ACP 启动真实示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包(package)级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 From 86d3bd6b9b95a36e9221ed6b6341be0db4b13984 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:21:36 +0800 Subject: [PATCH 079/113] docs(i18n): clarify review terminology --- docs/core-data-structures/web.i18n.yaml | 2 +- docs/core-data-structures/web.zh.md | 2 +- .../0002-js-expression-disabled-filesystem-tools.i18n.yaml | 2 +- .../0002-js-expression-disabled-filesystem-tools.zh.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/web.i18n.yaml b/docs/core-data-structures/web.i18n.yaml index f2552bd442..912c1decbe 100644 --- a/docs/core-data-structures/web.i18n.yaml +++ b/docs/core-data-structures/web.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write web.md: 20d07240c9d9fea2f1f5abbac810f349a3e81f9b -web.zh.md: f67b0f576315809109eecb545c0501e93bac9fd3 +web.zh.md: 68ceed04bb0b80f32ed704118f1fc25f48a0da70 diff --git a/docs/core-data-structures/web.zh.md b/docs/core-data-structures/web.zh.md index f67b0f5763..68ceed04bb 100644 --- a/docs/core-data-structures/web.zh.md +++ b/docs/core-data-structures/web.zh.md @@ -50,7 +50,7 @@ interface WebSearchResult { } ``` -`content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是可移植的引用表面。一个 source 必有 `url`;`title`/`snippet`/`publishedAt` 可选,因为并非每个提供方都返回它们——Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 +`content` 是提供方可选生成的回答文本(Exa 和 DeepSeek 不返回;Perplexity 返回生成式回答)。`sources[]` 是一套可跨提供方使用的引用数据结构。一个 source 必有 `url`;`title`/`snippet`/`publishedAt` 可选,因为并非每个提供方都返回它们——Perplexity 的引用可能只有 URL,强迫适配器编造其余字段会让 seam 说谎。`dsh-tool-web` 渲染时使用 `title ?? hostname(url)`。 ```ts type-equiv /** diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index 83106b4794..7145f7989d 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: 440c0642497930cf50cd799bf77af4460e29c2da +0002-js-expression-disabled-filesystem-tools.zh.md: 555d6efe1f2389d121210d8b559a863c0284afc4 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 440c064249..555d6efe1f 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -16,7 +16,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 影响 -七个文件系统场景和一个混合工作区编辑场景调用了注册表中不存在的工具。其结构化会话日志携带 `ToolNotFoundError`(code 为 `UNKNOWN_TOOL`),stdout 渲染出通用的失败工具卡片。快照套件通过了,因为两个表面都与刷新后的 fixture(测试前置数据)匹配;它证明的是回归的确定性回放,而非文件系统行为的正确性。 +七个文件系统场景和一个混合工作区编辑场景调用了注册表中不存在的工具。其结构化会话日志携带 `ToolNotFoundError`(code 为 `UNKNOWN_TOOL`),stdout 渲染出通用的失败工具卡片。快照套件通过了,因为结构化会话日志和 stdout 渲染出的通用失败工具卡片均与刷新后的 fixture(测试前置数据)匹配;它证明的是回归的确定性回放,而非文件系统行为的正确性。 实际运行的受限默认模式并未获得意外的文件系统访问权限。一个简单的插值修复反而会制造该风险:权限预设在运行时更新 bash 沙箱和审批状态,但无法挂载、卸载或约束文件系统栈。 From 06143b1a86fcee75d3f2887717f908400e2e6375 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 14:36:44 +0800 Subject: [PATCH 080/113] fix: cr --- .../2026-07-23-trajectory-step-cell.i18n.yaml | 4 +- .../2026-07-23-trajectory-step-cell.md | 4 +- .../2026-07-23-trajectory-step-cell.zh.md | 4 +- .../client/ui-trajectory/src/client/layout.ts | 66 +++++++++++++++---- .../ui-trajectory/tests/layout.spec.tsx | 63 ++++++++++++++++++ 5 files changed, 124 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml index fb39ae1301..1702c90c43 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d -2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55 +2026-07-23-trajectory-step-cell.md: 414c3aac856fb5e60f0e4cf42f8e7b410cdf3413 +2026-07-23-trajectory-step-cell.zh.md: aa76b422f165ebf6918b3781fdfe38797a34ba51 diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md index 42d896b81a..414c3aac85 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md @@ -14,9 +14,9 @@ The trajectory tab needs a reusable step row and turn-list chrome that can show - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection. - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8. -- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only (including the empty fallback when there is no text block), and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). `user/message` has no wire turn, so each User row is enclosed in the next assistant/steering turn, else the in-flight `partial` turn, else `lastAssistantTurn + 1` (or `1`). Context nodes emit no cell but still advance the Message duration cursor. -[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time (including skipped context); Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md index c6bcde7deb..aa76b422f1 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md @@ -14,9 +14,9 @@ trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展 - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。 - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。 -- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。 +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上(含无 text 块时的空回退行),并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。`user/message` 无线上 turn,故每条 User 行归入下一 assistant/steering 的 turn,否则归入进行中的 `partial` turn,否则为 `lastAssistantTurn + 1`(或 `1`)。context 节点不产出单元格,但仍推进 Message 耗时游标。 -[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间(含跳过的 context);Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 ## Alternatives considered diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 03bfcb6893..e188498554 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -54,6 +54,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>() let index = 0 let prevAbsTime: number | null = null + let lastAssistantTurn: number | null = null const bucket = (turn: number) => { let entry = turns.get(turn) @@ -74,9 +75,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T steps.set(step, list) } - for (const node of nodes) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ + if (node === undefined) continue if (node.kind === 'user' || node.kind === 'steering') { - const turn = node.kind === 'steering' ? node.turn : 0 + // user/message has no turn on the wire; enclose it in the next assistant + // (or partial) turn, else open the turn after the last assistant. + const turn = node.kind === 'steering' + ? node.turn + : enclosingUserTurn(nodes, i, partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { @@ -96,6 +104,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index prevAbsTime = finiteTime(node.time) ?? prevAbsTime + lastAssistantTurn = node.turn + continue + } + if (node.kind === 'context') { + // No trajectory cell, but the surface still advances the duration cursor. + prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } if (node.kind === 'tool-result') { @@ -149,6 +163,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T }) } + // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. const prologue = turns.get(0) if (prologue !== undefined) { turns.delete(0) @@ -269,11 +284,9 @@ function expandAssistant( index: ++index, kind: 'message', text: summarizeText(block.text), timeSeconds: messageDuration, } - if (!usageAttached && usage !== undefined) { - if (usage.inputTokens !== undefined) cell.input = usage.inputTokens - if (usage.outputTokens !== undefined) cell.output = usage.outputTokens - if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens - usageAttached = true + if (!usageAttached) { + attachUsage(cell, usage) + usageAttached = usage !== undefined } out.push({ absTime: nodeAbs, cell }) continue @@ -302,14 +315,45 @@ function expandAssistant( } if (out.length === 0 && !streaming) { - out.push({ - absTime: nodeAbs, - cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration }, - }) + // Reasoning-only / empty success still owns provider usage on the Message row. + const cell: TrajectoryCellProps = { + index: ++index, kind: 'message', text: '', timeSeconds: messageDuration, + } + attachUsage(cell, usage) + out.push({ absTime: nodeAbs, cell }) } return out } +/** + * Turn that encloses a user/message: next assistant/steering turn, else the + * in-flight partial, else the turn after the last finalized assistant (or 1). + */ +function enclosingUserTurn( + nodes: ConversationSnapshot['nodes'], + userIndex: number, + partial: ConversationSnapshot['partial'], + lastAssistantTurn: number | null, +): number { + for (let i = userIndex + 1; i < nodes.length; i++) { + const n = nodes[i] + /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ + if (n === undefined) continue + if (n.kind === 'assistant' || n.kind === 'steering') return n.turn + } + if (partial !== null) return partial.turn + if (lastAssistantTurn !== null) return lastAssistantTurn + 1 + return 1 +} + +/** Copy provider usage onto a Message cell when present. */ +function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void { + if (usage === undefined) return + if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.outputTokens !== undefined) cell.output = usage.outputTokens + if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens +} + function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> { const map = new Map<string, ToolResultNode>() for (const node of nodes) { diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 4dc395221d..9773f6fe57 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -140,4 +140,67 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') }) + + it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0, + blocks: [{ kind: 'text', text: 'ok1' }], + }, + { kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0, + blocks: [{ kind: 'text', text: 'ok2' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns.map((t) => t.turn)).toEqual([1, 2]) + expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1']) + expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2']) + }) + + it('keeps usage on the fallback Message row when assistant has no text block', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, + blocks: [{ kind: 'reasoning', text: '…' }], + usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 }, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') + expect(message).toMatchObject({ + text: '', input: 11, output: 22, think: 3, + }) + }) + + it('advances the duration cursor over context nodes', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, + blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }], + }, + { + kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', + call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'context', seq: 4, time: 9_000, + content: [{ type: 'text', text: 'extra' }], source: null, + }, + { + kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0, + blocks: [{ kind: 'text', text: 'done' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups + .flatMap((g) => g.cells) + .find((c) => c.kind === 'message' && c.text === 'done') + // From context at 9s, not from the earlier user/tool surfaces. + expect(message?.timeSeconds).toBe(1) + }) }) From 9b38ccad54e16a61d32d57ea9b09fe03baa15d83 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:42:36 +0800 Subject: [PATCH 081/113] docs(i18n): clarify optional rounds and test layout --- docs/i18n/terminology.md | 2 +- docs/testing.i18n.yaml | 4 ++-- docs/testing.md | 2 +- docs/testing.zh.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 0087f967f8..aac0f1f461 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -52,7 +52,7 @@ | loader | loader | | | | | manifest | manifest | manifest(元数据清单) | | | | monorepo | monorepo | | | | -| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 | +| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 | | schema | schema | | | | | schema DSL | schema DSL | | | | | seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 679b80380c..8ebdff8c55 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -testing.md: 5a18397ba2431a4c4f2595d32d9de6fe3ddeb6f4 -testing.zh.md: 9cb4bfa2c2d23e54fa0c90c0e901944dc1a64165 +testing.md: fd38fb7b20d76ef48c81c86badcf501f7c0dbd4e +testing.zh.md: 4584492350aefd5d72692093b08c0dcd4910a8af diff --git a/docs/testing.md b/docs/testing.md index 5a18397ba2..fd38fb7b20 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,7 +6,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers -- **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). +- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external presentation. ACP boots the real example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 9cb4bfa2c2..4584492350 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -6,10 +6,10 @@ ## 层级 -- **单元测试**(`pnpm run test`):vitest 运行 `packages|examples/*/tests/**/*.spec.ts`;测试与被测代码位于同一个包中,测试文件放在该包的 `tests/**` 下。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 +- **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外呈现。ACP 启动真实示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包(package)级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外呈现。ACP 启动真实示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 ## 带密钥策略:推理在这里很便宜 From 1b0988c92a696be4a41302f5a4db19e208990718 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:56:48 +0800 Subject: [PATCH 082/113] docs(i18n): address core translation review --- docs/core-data-structures/sandbox.i18n.yaml | 2 +- docs/core-data-structures/sandbox.zh.md | 2 +- docs/core-data-structures/subagent.i18n.yaml | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- .../0002-js-expression-disabled-filesystem-tools.i18n.yaml | 2 +- .../0002-js-expression-disabled-filesystem-tools.zh.md | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 3ef2f7cf69..f8189f4e15 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: bd33ffa4cacff22fbd7e0f24648a034310e7cded +sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index bd33ffa4ca..9a52f12675 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -2,7 +2,7 @@ [English](sandbox.md) | 中文 -[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将同世界子进程的 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 和远程执行是完整能力 seam 的兄弟实现,而非 `ctx.sandbox` 的提供方。 +[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 和远程执行是完整能力 seam 的兄弟实现,而非 `ctx.sandbox` 的提供方。 源码:[`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 0b6410142f..d8d6a493d7 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073 -subagent.zh.md: 0f255ac79258d91a3305c4e2a9c9f943f5674c1c +subagent.zh.md: dac48b624f6e0cfc28737e3e1a2774ba2d97e85b diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 0f255ac792..dac48b624f 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -10,7 +10,7 @@ subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [ba ## 两类能力,两种发现方式 -提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性(steering(中途引导)、resume)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法——方法的存在即为能力,TypeScript 的类型收窄即为发现机制。 +提供方通过一个静态描述符公布其**启动时**特性,服务在 run 存在之前即行检查;如果请求依赖提供方不具备的特性,会被大声拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。**运行时**特性(steering(中途引导)、恢复)则是 [`SubagentRun`](#a-live-run-subagentrun) 上的可选方法——方法的存在即为能力,TypeScript 的类型收窄即为发现机制。 ```ts type-equiv /** diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index 7145f7989d..2aad7141c5 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: 555d6efe1f2389d121210d8b559a863c0284afc4 +0002-js-expression-disabled-filesystem-tools.zh.md: b103ec6de5d6d6406ba48ec34f6ebb479e472352 diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 555d6efe1f..b103ec6de5 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -4,11 +4,11 @@ Status: resolved -## 概要 +## 摘要 ACP(Agent Client Protocol)示例试图通过 `disabled: !!js ...` 有条件地启用文件系统插件,但 Cordis 仅在插件 `config` 内部对 JavaScript 表达式求值。原始的表达式对象为 truthy,因此文件系统栈始终处于禁用状态。快照刷新随后将 `UNKNOWN_TOOL` 结果接受为新的预期输出。修复方案改用显式的文件系统 overlay,并增加了静态配置守卫和快照结果守卫。 -## 摘要 +## 概述 默认的 ACP 组合有意只启用 bash,因为其沙箱无法约束进程内的文件系统提供方。文件系统快照场景仍然需要 `read`、`write` 和 `edit`,因此这些插件被放在默认的 `cordis.yml` 中,并附带一个 `disabled` 表达式,意图仅在全权限启动和快照模式下启用它们。 From b02b438667d29b4cdc3ec83e4ea95404945e9d7e Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 15:08:36 +0800 Subject: [PATCH 083/113] refactor(agent): align delivery method names --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 6 +- ...ied-send-and-coalesced-user-messages.zh.md | 6 +- ...7-24-intent-named-agent-delivery.i18n.yaml | 4 +- .../2026-07-24-intent-named-agent-delivery.md | 12 ++-- ...26-07-24-intent-named-agent-delivery.zh.md | 12 ++-- docs/agent-lifecycle.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 14 ++-- docs/cookbook/extension-cookbook.zh.md | 14 ++-- docs/cordis-catalog/events.md | 4 +- docs/core-data-structures/core.md | 12 ++-- docs/core-data-structures/persistence.md | 2 +- .../core-data-structures/session-reference.md | 2 +- docs/defensive-patterns.md | 2 +- docs/i18n/style-samples.md | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../cordis-agent/tests/cordis-tools.e2e.ts | 10 +-- .../headless-agent/tests/code-mode.e2e.ts | 4 +- .../headless-agent/tests/coding-task.e2e.ts | 2 +- .../headless-agent/tests/compaction.e2e.ts | 2 +- .../headless-agent/tests/full-loop.e2e.ts | 2 +- examples/headless-agent/tests/resume.e2e.ts | 4 +- .../headless-agent/tests/todo-write.e2e.ts | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 10 +-- .../tests/compact-loop-repro.spec.ts | 10 +-- packages/context/session-reference/README.md | 2 +- .../time-context/tests/time-context.spec.ts | 8 +-- .../tests/workspace-context.e2e.ts | 8 +-- .../tests/workspace-context.spec.ts | 8 +-- .../cordis/tool-cordis/src/api-catalog.ts | 20 +++++- .../cordis/tool-cordis/tests/inspect.spec.ts | 2 + .../tool-cordis/tests/integration.spec.ts | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 16 ++--- packages/core/agent-loop/src/inbox.ts | 2 +- .../agent-loop/tests/agent-initiator.spec.ts | 2 +- packages/core/agent-loop/tests/agent.spec.ts | 12 ++-- packages/core/agent-loop/tests/cancel.spec.ts | 4 +- .../tests/config-session-id.spec.ts | 10 +-- .../tests/contract-regressions.spec.ts | 8 +-- .../agent-loop/tests/coverage-edges.spec.ts | 6 +- .../agent-loop/tests/inbox-invariant.spec.ts | 8 +-- .../agent-loop/tests/interception.spec.ts | 6 +- packages/core/agent-loop/tests/loop.spec.ts | 8 +-- .../core/agent-loop/tests/properties.spec.ts | 6 +- .../agent-loop/tests/request-cache.e2e.ts | 4 +- .../tests/request-reconstruction.spec.ts | 2 +- .../agent-loop/tests/request-recovery.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 12 ++-- .../agent-loop/tests/scope-lifecycle.spec.ts | 6 +- .../core/agent-loop/tests/tool-calls.spec.ts | 32 ++++----- .../core/agent-loop/tests/tool-order.spec.ts | 4 +- .../core/agent-loop/tests/turn-stop.spec.ts | 4 +- packages/core/agent/README.md | 8 +-- packages/core/agent/src/types.ts | 14 ++-- packages/core/agent/tests/agent.spec.ts | 6 +- .../agent-spine-demo/tests/agent-core.spec.ts | 8 +-- packages/examples/cli-demo/src/cli.ts | 2 +- packages/examples/cli-demo/tests/cli.spec.ts | 2 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 4 +- .../command-goal/tests/command-goal.spec.ts | 4 +- packages/goal/goal-session/src/index.ts | 2 +- packages/goal/goal-session/src/prompt.ts | 2 +- .../goal-session/tests/goal-session.spec.ts | 30 ++++---- packages/goal/goal/tests/goal.spec.ts | 4 +- packages/goal/tool-goal/README.md | 2 +- packages/goal/tool-goal/src/authority.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 4 +- .../tests/repeat-tool-guard.spec.ts | 36 +++++----- .../hooks/hooks-claude/tests/bridge.spec.ts | 20 +++--- .../hooks-claude/tests/coverage-cases.ts | 60 ++++++++-------- .../hooks/hooks-codex/tests/bridge.spec.ts | 12 ++-- .../hooks/hooks-codex/tests/coverage-cases.ts | 68 +++++++++---------- packages/host/runtime/src/api-proxy.ts | 2 +- .../host/runtime/tests/host-runtime.spec.ts | 10 +-- .../tests/loader-composition.spec.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 26 +++---- .../plan/plan-mode/tests/integration.spec.ts | 8 +-- packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 4 +- .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../tests/fixtures/crash-child.ts | 2 +- .../tests/multi-subagent.spec.ts | 4 +- .../subagent-fork/tests/subagent-fork.spec.ts | 14 ++-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 2 +- .../tests/structured.spec.ts | 8 +-- .../tests/subagent-inprocess.spec.ts | 2 +- .../subagent-spawn/tests/spawn.e2e.ts | 2 +- .../tests/subagent-spawn.spec.ts | 4 +- packages/tasks/tasks/tests/tasks.spec.ts | 4 +- .../todo/tool-todo/tests/integration.spec.ts | 4 +- packages/ui/acp/README.md | 2 +- packages/ui/acp/acp-feature-support.md | 2 +- packages/ui/acp/src/index.ts | 6 +- packages/ui/acp/tests/dispose.spec.ts | 4 +- packages/ui/acp/tests/edges.spec.ts | 2 +- packages/ui/jsonrpc/src/server.ts | 2 +- packages/ui/jsonrpc/tests/server.spec.ts | 2 +- packages/ui/tui/README.md | 8 +-- packages/ui/tui/src/index.ts | 2 +- packages/ui/tui/tests/harness.ts | 4 +- packages/ui/tui/tests/tui.spec.ts | 12 ++-- .../tool-ralph/tests/integration.spec.ts | 2 +- scripts/gen-cordis-api.ts | 47 +++++++++++-- scripts/gen-doc-graphs.ts | 2 +- 113 files changed, 462 insertions(+), 405 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index d175853372..b0afe427ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-unified-send-and-coalesced-user-messages.md: 7ee51ed18cbf6a12136abe67f412251c4c1f0eb3 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d260f1ba396cf9b6a90773b218a8df3d158d95c9 +2026-07-22-unified-send-and-coalesced-user-messages.md: bf0ae468c4783b73e2dbd0e1bc50b9bd2f50cb3f +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 17913d2636e3ee5e5ae69f9c554935ba861d14d9 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 7ee51ed18c..bf0ae468c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -12,7 +12,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Decision -**One acceptance mechanism, four intent helpers.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface also exposes that mechanism as `acceptInput(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. +**One acceptance mechanism, four intent helpers.** The concrete loop resolves `followup`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `followup` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes that mechanism as `send(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. **inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. @@ -34,9 +34,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj ## Consequences -The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `acceptInput` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `send` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. -Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple. +Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking follow-up, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index d260f1ba39..17913d2636 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -12,7 +12,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 决策 -**一种接受机制,四种意图辅助方法。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口还将该机制暴露为 `acceptInput(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 +**一种接受机制,四种意图辅助方法。** 具体循环把 `followup`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`followup` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口将该机制暴露为 `send(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 **inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 @@ -34,9 +34,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` ## 后果 -具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `acceptInput` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `send` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 -在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。 +在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一条会唤醒驱动器的后续消息一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。 ## 相关 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml index 69ec4e0895..ec8c9d105f 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-intent-named-agent-delivery.md: 54ea6353a516830795d51c395706241a4570260d -2026-07-24-intent-named-agent-delivery.zh.md: 0d903a0d5dd383a154ade02ee8bed607d1f986b1 +2026-07-24-intent-named-agent-delivery.md: 32b0502350063610efff746cbef779e8225055eb +2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md index 54ea6353a5..32b0502350 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md @@ -14,14 +14,14 @@ Sharing helper implementations through an abstract `Agent` class also makes the `Agent` is a structural interface with four intent-named delivery helpers: -- `send()` queues an ordinary turn and wakes the driver. +- `followup()` queues an ordinary turn and wakes the driver. - `queue()` queues an ordinary turn without waking an idle driver. - `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn. - `inject()` appends model-facing context without running the model. -`send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message. +`followup`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` names the waking next-turn operation used for both initial prompts and later independent prompts. -`Agent` also exposes `acceptInput(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. +`Agent` also exposes `send(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The method accepts the delivery facts as one resolved input; acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface. @@ -29,11 +29,11 @@ The target/wakeup matrix is an explicit advanced part of the structural `Agent` **Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state. -**Use configurable `send` as the primitive.** Even mandatory routing arguments would make the common method carry advanced concerns. Keeping `send` semantic preserves its simple defaulted call shape; the separate discriminated input type rejects attached contexts on injection. +**Use configurable `send(content, options)` as the primitive.** Optional routing fields would let advanced-looking calls silently become ordinary sends. One mandatory discriminated input keeps the resolved route explicit and rejects attached contexts on injection. -**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** A public method must not describe itself as internal. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work; `acceptInput` names the synchronous guarantee instead. +**Name the primitive `acceptInput`, `sendInternal`, or `addMessageAdvanced`.** `acceptInput` describes the synchronous acceptance boundary but not the caller's delivery action. A public method must not describe itself as internal, and `addMessageAdvanced` is inaccurate because the input may later be discarded. -**Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias. +**Use `send(content, options)` as the waking-turn helper.** This reserves the shortest delivery name for one preset and forces callers with complete target/wakeup facts through a less direct primitive name. `followup` distinguishes the next-turn/wakeup intent while leaving `send` for the resolved operation. **Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md index 0d903a0d5d..ce8860b397 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md @@ -14,14 +14,14 @@ Status: implemented `Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法: -- `send()` 将一个普通轮次入队并唤醒驱动器。 +- `followup()` 将一个普通轮次入队并唤醒驱动器。 - `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 - `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。 - `inject()` 追加面向模型的上下文,但不运行模型。 -`send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息。 +`followup`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。`followup` 为唤醒式下一轮操作命名,这项操作既用于初始提示词,也用于后续的独立提示词。 -`Agent` 还公开 `acceptInput(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。这个名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 +`Agent` 还公开 `send(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。调用方以一个解析后的输入向该方法提交各项投递事实;接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。 @@ -29,11 +29,11 @@ Status: implemented **让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。 -**使用可配置的 `send` 作为原语。** 即使强制提供所有路由参数,也会让这个常用方法承载高级用法的复杂性。让 `send` 只表达语义意图,可以保留其带默认值的简单调用形式;单独的可辨识输入类型则会拒绝为注入附加上下文。 +**使用可配置的 `send(content, options)` 作为原语。** 可选路由字段会让看似高级的调用悄然变成普通投递。一个各字段均为必填项的可辨识输入既能让解析后的路由保持显式,也会拒绝为注入附加上下文。 -**把原语重命名为 `sendInternal` 或 `addMessageAdvanced`。** 公开方法不应在名称中把自己称为内部方法。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作;`acceptInput` 描述的则是同步边界所保证的事实。 +**把原语命名为 `acceptInput`、`sendInternal` 或 `addMessageAdvanced`。** `acceptInput` 描述了同步接受边界,却没有描述调用方的投递操作。公开方法不应在名称中把自己称为内部方法,`addMessageAdvanced` 也不准确,因为输入可能在之后被丢弃。 -**保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名。 +**使用 `send(content, options)` 作为唤醒轮次的辅助方法。** 这会让最简短的投递名称只表示一种预设操作,并迫使持有完整 target/wakeup 信息的调用方改用一个不够直接的原语名称。`followup` 明确区分下一轮/唤醒意图,并把 `send` 留给解析后的操作。 **先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 9a97873a17..5f1e43eb17 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -17,7 +17,7 @@ sequenceDiagram participant Session participant Persistence participant SDK as UI or SDK listener - User->>Agent: send(content) + User->>Agent: followup(content) Agent-->>SDK: <code>agent/inbox/enqueue</code> Agent->>Driver: queued work wakes driver Driver-->>SDK: <code>agent/status</code> running diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1125393ba3..214e2c9fba 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 937d0e5dc2d41140506ecb9483e1b2d35abcb41f -architecture.zh.md: 255035c4018b9a4edc788441225d47cb43757c76 +architecture.md: 76c58e03282ef6d736da7d65b05c534c05c4c318 +architecture.zh.md: dddccf1e9238e617a453395731ee3f620ba5749d diff --git a/docs/architecture.md b/docs/architecture.md index 937d0e5dc2..76c58e0328 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,7 +131,7 @@ Session events are turn-enclosed; reload closes an interrupted tail with a synth ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `queue()`, `steer()`, and `inject()`; callers may use mandatory-field `acceptInput()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. +`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent helpers `followup()`, `queue()`, `steer()`, and `inject()`; callers with exact routing facts use mandatory-field `send()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. ### Agent Scope diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 255035c401..dddccf1e92 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,7 +131,7 @@ forever: ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`queue()`、`steer()` 和 `inject()`;调用方可以使用各字段均为必填项的 `acceptInput()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 +`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的辅助方法 `followup()`、`queue()`、`steer()` 和 `inject()`;持有确切路由信息的调用方使用各字段均为必填项的 `send()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 ### Agent 作用域 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 5d5dc81f2a..03d868a264 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 056be4298ed2bec2b78ed777d58f1f8a60a34b78 -extension-cookbook.zh.md: 41cdd4a7d14f32494d1dd5ae4a63c098d5640bdc +extension-cookbook.md: c13b46e06a3b34512cd371e6a4868a6e932a575f +extension-cookbook.zh.md: aeb5f905278c07344c68d80da05dc5daf299b4f6 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 056be4298e..c13b46e06a 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -36,7 +36,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an ## A UI plugin -A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`. +A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. ```ts import type { Context } from 'cordis' @@ -54,13 +54,13 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) } ``` ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. +A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `followup()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam. @@ -99,9 +99,9 @@ Every product feature maps to a listener on a documented extension seam — the |---|---| | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | -| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | +| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | -| Queued + steering messages | core `Agent.send()` / `Agent.steer()` | +| Queued + steering messages | core `Agent.followup()` / `Agent.steer()` | | Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | @@ -118,8 +118,8 @@ Every product feature maps to a listener on a documented extension seam — the | MCP | one plugin per server: discover tools → `ctx.tools.register()` | | Skills | section + tool registration; `inject()` skill content on invocation | | Memory | section provider + tool | -| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | -| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` | +| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | +| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` | | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | | Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) | | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 41cdd4a7d1..aeb5f90527 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -36,7 +36,7 @@ export function apply(ctx: Context) { ## UI 插件 -UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。 +UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。 ```ts import type { Context } from 'cordis' @@ -54,13 +54,13 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) } ``` ## 客户端驱动插件(外部协议桥接) -*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。 +*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `followup()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。 `packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。 @@ -99,9 +99,9 @@ export function apply(ctx: Context) { |---|---| | 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | -| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | +| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | -| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | +| 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` | | 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | @@ -118,8 +118,8 @@ export function apply(ctx: Context) { | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | | 记忆 | section provider + 工具 | -| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | -| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` | +| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | +| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | | 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d2a57d21b2..ed9bebdc92 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -146,7 +146,7 @@ Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/t ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs and does not emit this. +A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `send()` routing bypasses the FIFOs and does not emit this. ```ts cordis-catalog /** @@ -154,7 +154,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection through - * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 82c7ebc24d..b409a37284 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -361,7 +361,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. + * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ @@ -392,7 +392,7 @@ The advanced acceptance form makes every default explicit and rules out attached ```ts type-equiv /** - * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * Fully specified input for {@link Agent.send}. Unlike the intent-named * helpers, this form applies no defaults: callers provide content, source, * contexts, metadata (including explicit `undefined`), target, and wakeup. * The union excludes attached contexts from non-waking next-step injection. @@ -423,7 +423,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * is the value returned by the accepting helper or {@link Agent.send}, * stable across this message's enqueue, dequeue, and discard events. Source * defaults, when applicable, are already applied, so these are the exact values * the item was accepted with. @@ -433,7 +433,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t * `steering/message`, not live-event routing data. */ interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ + /** The id returned by the accepting helper or {@link Agent.send}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -489,7 +489,7 @@ interface Agent { * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options?: SendOptions): AgentMessageId + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Queue an ordinary message without waking an idle driver. The item retains @@ -539,7 +539,7 @@ interface Agent { * @param input - the resolved content, attribution, context, metadata, and routing facts. * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. */ - acceptInput(input: ResolvedAgentInput): AgentMessageId + send(input: ResolvedAgentInput): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 9bfdca56bd..ee01363c50 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `followup()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md index dbe3c43d35..d9a73c08c6 100644 --- a/docs/core-data-structures/session-reference.md +++ b/docs/core-data-structures/session-reference.md @@ -36,7 +36,7 @@ interface SessionReferenceCandidate { ## Prepared messages -Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `send()` or `steer()` call. +Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `followup()` or `steer()` call. ```ts type-equiv /** Message payload and the zero-or-one durable snapshot contexts bound to it. */ diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index fe74a9d19f..a6fe43ef69 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma ## Async state is not synchronous state -`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. +`agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. ## Dispose must reach quiescence, not just request it diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index dd970b7c12..b20b2a4c90 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -28,9 +28,9 @@ **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。 -> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. +> **Async state is not synchronous state** — `agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. -**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 +**异步状态不等同于同步瞬时状态**:调用 `agent.followup()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 ## ③ 测试政策清单 diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index b95ab420af..487f0517b1 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index b82fa568f1..56cc640977 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index ae3651fdaf..b5f8e27564 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -42,7 +42,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' + 'cordis event and logs every change with console.log. Reply "mounted" once done.', @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif }) expect(resultText(mid)).toContain('dyn-') - agent.send([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) + agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ @@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' @@ -119,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' @@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.send([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) + agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index a2bc25cbcc..86c1559b83 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -311,7 +311,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p ctx = await codeModeHarness(workdir) const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' @@ -363,7 +363,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.send([{ + handle.agent.followup([{ type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', }]) diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index a5f525e5c3..4a827858bb 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index fcebddb863..a96b7f3611 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }) const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ + agent.followup([{ type: 'text', text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' + 'time using cat (a separate bash command for each). After reading all four, tell me how ' diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index db2eec63fc..4f61ec3fa3 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) + agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index 01c7d52393..a4ded767da 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, })).agent - first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) + first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() ctx = undefined @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) - resumed.send([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) + resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index daf5c018b1..c3053572d9 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: + agent.followup([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' + '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' + 'Send both in one todo_write call, then reply with the single word DONE.' }]) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 7d6d9de97d..8adf2165e0 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => { const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') - agent.send([{ type: 'text', text: 'inspect the current session' }]) + agent.followup([{ type: 'text', text: 'inspect the current session' }]) await waitForIdle(ctx, agent) const result = findEvent(events(agent), 'tool/result') @@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run echo integration-ok' }]) + agent.followup([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) const log = events(agent) @@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run exit 9' }]) + agent.followup([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) const toolResult = findEvent(events(agent), 'tool/result') @@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) + agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) const firstResult = findEvent(events(agent), 'tool/result') @@ -200,7 +200,7 @@ describe('bash tool through the agent loop', () => { expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) // The next turn collects the output through the generic task tool. - agent.send([{ type: 'text', text: 'collect it' }]) + agent.followup([{ type: 'text', text: 'collect it' }]) await waitForIdle(ctx, agent) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.isError).toBe(false) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f9f61e4096..83c18f78b4 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -197,7 +197,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }) - agent.send([{ type: 'text', text: 'do a routed multi-step task' }]) + agent.followup([{ type: 'text', text: 'do a routed multi-step task' }]) await waitForIdle(ctx, agent) expect(agent.session.requestHeader()?.config.model).toBe('mock') @@ -215,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do tool work' }]) + agent.followup([{ type: 'text', text: 'do tool work' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -241,7 +241,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () const { ctx } = await harness(8) try { const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do a long multi-step task' }]) + agent.followup([{ type: 'text', text: 'do a long multi-step task' }]) await waitForIdle(ctx, agent) const events = [...agent.session.events] @@ -297,7 +297,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () }) seedOverflowHistory(agent) - agent.send([{ type: 'text', text: 'continue from history' }]) + agent.followup([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(2) @@ -360,7 +360,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () try { const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' }) seedOverflowHistory(agent) - agent.send([{ type: 'text', text: 'continue from history' }]) + agent.followup([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(3) diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index b3a4b2013d..4fca9a9977 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -5,7 +5,7 @@ ## Public API - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched. -- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `send()` or `steer()`. +- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. ## Snapshot semantics diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 2f9d679f21..424363d4b1 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -42,7 +42,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session, status: 'running', ctx: new Context(), - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { @@ -52,7 +52,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } @@ -374,7 +374,7 @@ describe('real agent-loop request history', () => { }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await agent.whenIdle() expect(laterSawReading).toBe(true) @@ -400,7 +400,7 @@ describe('real agent-loop request history', () => { })) const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await agent.whenIdle() expect(adapter.requests).toHaveLength(2) diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index f80ee5acde..fbeacb5496 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('obeys a probe instruction loaded from the workspace', async () => { const live = await harness() - live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(PROBE) @@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`) await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n') - live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) await waitForIdle(live.ctx, live.agent) expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) @@ -99,11 +99,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { const live = await harness() await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n') - live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`) - live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) + live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) await waitForIdle(live.ctx, live.agent) const events = [...live.agent.session.events] diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 7ce1c4da28..c8c33f3a4b 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -177,7 +177,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { options: {}, session, status: 'idle', - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { @@ -188,7 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } @@ -1717,11 +1717,11 @@ describe('dynamic nested workspace context injection', () => { }, })) - agent.send([{ type: 'text', text: 'read and abort' }]) + agent.followup([{ type: 'text', text: 'read and abort' }]) await agent.whenIdle() expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) - agent.send([{ type: 'text', text: 'retry the read' }]) + agent.followup([{ type: 'text', text: 'retry the read' }]) await agent.whenIdle() const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 801ec729b6..bb8392825f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/enqueue', mode: 'emit', signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void', - jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).', }, { @@ -1181,7 +1181,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n acceptInput(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n}', }, { name: 'AgentCancelCause', @@ -1547,6 +1547,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, + { + name: 'LlmAdapter', + declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}', + }, { name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', @@ -1755,6 +1759,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}', }, + { + name: 'Session', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + }, { name: 'SessionAvailability', declaration: 'export type SessionAvailability = \'live\' | \'persisted\';', @@ -1887,6 +1895,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchRequest', declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, + { + name: 'SessionSurface', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -2019,6 +2031,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';', }, + { + name: 'SurfaceIntent', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', + }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 3085cfd057..18b4d04df7 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -61,6 +61,8 @@ describe('cordis_inspect', () => { // generated TYPE_API — a consumer can see field types, not just names). expect(report).toContain('type shapes (referenced by the signatures above') expect(report).toContain('export interface ToolExecution') + expect(report).toContain('export class Session') + expect(report).toContain('export interface SessionSurface') // A type only reachable through a NOT-live service (e.g. bash) is scoped out. expect(report).not.toContain('export interface BashRunResult') // The inherited ctx surface closes the section. diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index d68f6be349..7f917e5b3c 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -47,7 +47,7 @@ describe('cordis tools through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) + agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) await waitForIdle(ctx, agent) const log = agent.session.events diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index cdbd512d0d..47250c354f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -`ReactLoopAgent.acceptInput()` implements the public fully resolved acceptance path. The `send()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `send()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `acceptInput()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. +`ReactLoopAgent.send()` implements the public fully resolved acceptance path. The `followup()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `followup()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `send()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 22b292b107..e09acfff40 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -232,7 +232,7 @@ export class ReactLoopAgent implements Agent { } /** Accept one fully resolved agent input through the concrete driver's routing matrix. */ - acceptInput(input: ResolvedAgentInput): AgentMessageId { + send(input: ResolvedAgentInput): AgentMessageId { this.assertNotDisposed() const id = AgentMessageId(randomUUID()) const { target, wakeup } = input @@ -251,8 +251,8 @@ export class ReactLoopAgent implements Agent { return id } - send(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.acceptInput({ + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId { + return this.send({ content, target: 'next-turn', wakeup: true, @@ -263,7 +263,7 @@ export class ReactLoopAgent implements Agent { } queue(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.acceptInput({ + return this.send({ content, target: 'next-turn', wakeup: false, @@ -274,7 +274,7 @@ export class ReactLoopAgent implements Agent { } steer(content: ContentBlock[], options?: SendOptions): AgentMessageId { - return this.acceptInput({ + return this.send({ content, target: 'next-step', wakeup: true, @@ -285,7 +285,7 @@ export class ReactLoopAgent implements Agent { } inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId { - return this.acceptInput({ + return this.send({ content, target: 'next-step', wakeup: false, @@ -488,9 +488,9 @@ export class ReactLoopAgent implements Agent { if (this._status !== 'disposed') { // Snapshot any still-pending inbox items, then CLEAR and mark disposed // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit - // order so a re-entrant send()/cancel() from a discard listener throws + // order so a re-entrant followup()/cancel() from a discard listener throws // `disposed` (or finds an empty inbox) instead of leaking or double- - // discarding an id. `send()` emits enqueue unconditionally, so the discard + // discarding an id. `followup()` emits enqueue unconditionally, so the discard // is unconditional too (even on an unpublished rollback) to keep every // enqueued id matched. const discarded = this.#inbox.pending() diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index f2e8addda2..0f93884515 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -58,7 +58,7 @@ export class Inbox { * True while a queued message wants to wake the driver — the "should the loop * run" signal read by the idle wait's fast path, the loop's idle-publish * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this - * false, so the driver stays parked until a waking send (or a waking item + * false, so the driver stays parked until a waking follow-up (or a waking item * ahead of it in FIFO order) drives the loop; the quiet item then rides along. */ get hasWakingQueued(): boolean { diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index c6c767d422..a2359562a8 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function send(agent: Agent, text: string): void { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Adapter that holds both drivers at the same awaited continuation. */ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 3a9fbd9a17..08b77711f9 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -48,7 +48,7 @@ function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): P } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('Agent', () => { @@ -83,7 +83,7 @@ describe('Agent', () => { await ctx.fiber.dispose() }) - it('acceptInput exposes the fully resolved delivery path without applying helper defaults', async () => { + it('send exposes the fully resolved delivery path without applying helper defaults', async () => { const adapter = new MockAdapter([textResponse('accepted')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -92,7 +92,7 @@ describe('Agent', () => { if (subject === agent) enqueued.resolve(message) }) - const id = agent.acceptInput({ + const id = agent.send({ content: [{ type: 'text', text: 'advanced input' }], source: { kind: 'plugin', plugin: 'advanced-caller' }, contexts: [], @@ -117,7 +117,7 @@ describe('Agent', () => { await ctx.fiber.dispose() }) - it('send() throws after disposal', async () => { + it('followup() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: Agent @@ -129,7 +129,7 @@ describe('Agent', () => { await fiber.dispose() await driverDone(agent) - expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') + expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) it('disposal discards still-pending inbox items so every id gets a terminal event', async () => { @@ -490,7 +490,7 @@ describe('Agent', () => { const { agent } = prepared prepared.markPublished() const dispose = prepared.startDriver() - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 62b75df154..32418fe112 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ @@ -63,7 +63,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/cancel-requested', (subject, cause) => { if (subject !== agent) return seen.push(`first:${cause.kind}`) - subject.send([{ type: 'text', text: 'queued by cancel observer' }]) + subject.followup([{ type: 'text', text: 'queued by cancel observer' }]) throw new Error('observer failed') }) ctx.on('agent/cancel-requested', (subject, cause) => { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 2103dd6831..d144127498 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -98,7 +98,7 @@ describe('config-driven session id', () => { first = ctx.agents.get(SessionId('config-exact-reload')) } expect(first).toBeDefined() - first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx, first!) await firstLoop.dispose() @@ -110,7 +110,7 @@ describe('config-driven session id', () => { } expect(second).toBeDefined() expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) await waitForIdle(ctx, second!) await ctx.sessions.flush(second!.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) @@ -335,7 +335,7 @@ describe('config-driven session id', () => { expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -354,7 +354,7 @@ describe('config-driven session id', () => { expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) - a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) + a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) await waitForIdle(ctx2, a2) await ctx2.fiber.dispose() }) @@ -375,7 +375,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent - a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 24eccf5cbd..2b85691151 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('session log records what agent/step-result actually produced', () => { @@ -818,7 +818,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { source: { kind: 'plugin', plugin: 'context-source' }, meta: { version: 1 }, }] - agent.send(content, { source, contexts }) + agent.followup(content, { source, contexts }) content[0]!.text = 'caller-mutated-send' source.plugin = 'caller-mutated-source' contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' } @@ -874,7 +874,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { notifiedContexts = info.contexts }) - agent.send([{ type: 'text', text: 'start' }]) + agent.followup([{ type: 'text', text: 'start' }]) await entered.promise expect(agent.status).toBe('running') const content = [{ type: 'text' as const, text: 'accepted-steer' }] @@ -991,7 +991,7 @@ describe('turn numbering continues across seeded sessions', () => { const turns: number[] = [] ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) - forked.send([{ type: 'text', text: 'continue' }]) + forked.followup([{ type: 'text', text: 'continue' }]) await new Promise<void>((resolve) => { ctx2.on('agent/status', (subject, status) => { if (subject === forked && status === 'idle') resolve() diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index ebc2287a83..f8ff5a74b9 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('inbox acceptance', () => { @@ -50,10 +50,10 @@ describe('inbox acceptance', () => { ctx.on('agent/inbox/enqueue', () => { queued += 1 }) expect(() => { - agent.send([{ type: 'text', text: 'first', bad: 1n } as never]) + agent.followup([{ type: 'text', text: 'first', bad: 1n } as never]) }).toThrow(/losslessly JSON-serializable/) expect(() => { - agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) + agent.followup([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) }).toThrow(/losslessly JSON-serializable/) expect(queued).toBe(0) expect(agent.session.events).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/inbox-invariant.spec.ts b/packages/core/agent-loop/tests/inbox-invariant.spec.ts index b1634afdb9..0d907f26d7 100644 --- a/packages/core/agent-loop/tests/inbox-invariant.spec.ts +++ b/packages/core/agent-loop/tests/inbox-invariant.spec.ts @@ -55,7 +55,7 @@ describe('inbox FIFO-conservation invariant', () => { return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -80,7 +80,7 @@ describe('inbox FIFO-conservation invariant', () => { return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false) @@ -110,7 +110,7 @@ describe('inbox FIFO-conservation invariant', () => { return { action: 'stop' as const } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(discards).toEqual([1]) // the dropped steering item was reported @@ -142,7 +142,7 @@ describe('inbox FIFO-conservation invariant', () => { agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } }) }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt plus the late steer both enqueued; both are matched (the prompt diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index b96dd48dbe..de721b1653 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } function events(agent: Agent): SessionEvent[] { @@ -128,7 +128,7 @@ describe('agent/prompt-submit', () => { ? downstream : { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] } }) - agent.send([{ type: 'text', text: 'original request' }], { + agent.followup([{ type: 'text', text: 'original request' }], { contexts: [{ content: [{ type: 'text', text: 'untrusted prefix' }], source: { kind: 'plugin', plugin: 'prefix' }, @@ -203,7 +203,7 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - agent.send([{ type: 'text', text: 'do something' }], { + agent.followup([{ type: 'text', text: 'do something' }], { contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }], }) await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8b8a4c8c92..8875b51d67 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } describe('agent loop', () => { @@ -539,7 +539,7 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } }) + agent.followup([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } }) await waitForIdle(ctx, agent) const user = agent.session.events.find(e => e.type === 'user/message') @@ -1083,9 +1083,9 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'user message' }]) + agent.followup([{ type: 'text', text: 'user message' }]) await Promise.resolve() - agent.send( + agent.followup( [{ type: 'text', text: 'plugin message' }], { source: { kind: 'plugin', plugin: 'test' } }, ) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 85efda0e4e..593b32ab38 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => { const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. - for (const text of texts) agent.send([{ type: 'text', text }]) + for (const text of texts) agent.followup([{ type: 'text', text }]) await idle // No message lost: every send appears as a user/message, in order. @@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => { const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) await idle } // Each send was drained at a separate turn start: N turns, 1..N. @@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => { for (const step of steps) { const idle = nextIdle(ctx, agent) lastIdle = idle - agent.send([{ type: 'text', text: step.text }]) + agent.followup([{ type: 'text', text: step.text }]) if (step.settle) await idle } await lastIdle diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index f1e1a1a367..37079b8565 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). - agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) + agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) await waitForIdle(ctx, agent) // Turn 2: a follow-up over the same (longer) prefix. - agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) + agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }]) await waitForIdle(ctx, agent) const usages = [...agent.session.events] diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 0efc76364a..a73218f345 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function send(agent: Agent, text: string) { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) } /** Assert `previous` is a strict value-prefix of `current`. */ diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 6f26d755a4..1fc44e3431 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -114,7 +114,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> { } function send(agent: Agent): void { - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) } function contextError(message = 'context too large'): LlmError { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 5c64bb92f9..2d8826868c 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -125,7 +125,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -153,7 +153,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -459,7 +459,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) // Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose). @@ -482,7 +482,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) await ctx1.sessions.flush(a1.session) @@ -510,7 +510,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent - a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] const seqs1 = events1.map(e => e.seq) @@ -537,7 +537,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) // …and a new turn continues numbering (turn 2) with contiguous seqs. - a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } }) + a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } }) await waitForIdle(ctx2, a2) const allSeqs = a2.session.events.map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 469a815d8b..8844e5c1a3 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => { if (event.type === 'user/message') heard.push('a-sees:user-message') }) - b.send(text('for b')) + b.followup(text('for b')) await waitForIdle(ctx, b) expect(heard).toEqual([]) // nothing of b's leaked into a's scope - a.send(text('for a')) + a.followup(text('for a')) await waitForIdle(ctx, a) expect(heard).toContain('a-sees:a:running') expect(heard).toContain('a-sees:user-message') @@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => { if (event.type === 'turn/start') { off(); resolve() } }) }) - agent.send(text('work')) + agent.followup(text('work')) await turnOpen await owner.dispose() expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true']) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 6597a3710e..0e7f1313cb 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => { ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) expect(gated.started).toEqual(['1', '2', '3']) gated.release('1'); gated.release('2'); gated.release('3') @@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => { async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3']) @@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => replacement.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(replacement.started).toEqual(['1']) @@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => { }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => initial.started.length === 2) initial.release('1') await until(() => events(agent).some(event => @@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2') await new Promise(r => setTimeout(r, 5)) @@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -294,7 +294,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1', '2']) @@ -323,7 +323,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1']) @@ -349,7 +349,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const gated = gatedParallelTool('p') ctx.tools.register(gated.tool) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) await new Promise(r => setTimeout(r, 5)) expect(gated.started).toEqual(['1']) @@ -376,7 +376,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 3) gated.release('3'); gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -397,7 +397,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) gated.release('2'); gated.release('1') await waitForIdle(ctx, agent) @@ -435,7 +435,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () = }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 1) gated.release('1') await waitForIdle(ctx, agent) @@ -465,7 +465,7 @@ describe('tool-call scheduler: abort handling', () => { } }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(gated.started).toEqual([]) @@ -497,7 +497,7 @@ describe('tool-call scheduler: abort handling', () => { return next() }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(gated.started).toEqual([]) @@ -527,7 +527,7 @@ describe('tool-call scheduler: abort handling', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) agent.cancel({ kind: 'user' }) gated.release('1') @@ -578,7 +578,7 @@ describe('tool-call scheduler: abort handling', () => { })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) agent.cancel({ kind: 'user' }) gated.release('1') diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 76b14e92c2..a39961bfbf 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { ctx, agent, adapter } } @@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha']) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index eb6e6b4da5..44e1fd8a7b 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -34,7 +34,7 @@ async function harness(adapter: MockAdapter): Promise<Context> { } function send(agent: Agent, text = 'go'): Promise<void> { - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) return agent.whenIdle() } @@ -116,7 +116,7 @@ describe('agent/turn-stop', () => { ctx.on('session/flush', (session) => { if (session !== agent.session || queued) return queued = true - agent.send([{ type: 'text', text: 'ordinary queued follow-up' }]) + agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }]) }) await send(agent) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index b09334d873..511ce32235 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,13 +54,13 @@ Turn and step boundaries and the model token stream are durable `session/event` ### Agent interface (`types.ts`) -`Agent` is a structural interface. `send()`, `queue()`, `steer()`, and `inject()` name common caller intents; `acceptInput(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `send()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. +`Agent` is a structural interface. `followup()`, `queue()`, `steer()`, and `inject()` name common caller intents; `send(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `followup()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content. -- `agent.send(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.followup(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message. - `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event. -- `agent.acceptInput(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata. +- `agent.send(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata. - `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` @@ -79,7 +79,7 @@ Turn and step boundaries and the model token stream are durable `session/event` #### What the model sees -The four helpers and `acceptInput` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. +The four intent helpers and fully resolved `send` path feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. #### Token effect diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 4a959bf79a..0f442e3f88 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -27,7 +27,7 @@ export interface AgentOptions { } /** - * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}. + * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. * An omitted source attests direct human input as `{ kind: 'user' }` and may * authorize policy consumers, so non-human producers must label their content. */ @@ -68,7 +68,7 @@ export function AgentMessageId(id: string): AgentMessageId { /** * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.acceptInput}, + * is the value returned by the accepting helper or {@link Agent.send}, * stable across this message's enqueue, dequeue, and discard events. Source * defaults, when applicable, are already applied, so these are the exact values * the item was accepted with. @@ -78,7 +78,7 @@ export function AgentMessageId(id: string): AgentMessageId { * `steering/message`, not live-event routing data. */ export interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.acceptInput}. */ + /** The id returned by the accepting helper or {@link Agent.send}. */ id: AgentMessageId content: ContentBlock[] source: MessageSource @@ -122,7 +122,7 @@ export interface HookContext { } /** - * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named + * Fully specified input for {@link Agent.send}. Unlike the intent-named * helpers, this form applies no defaults: callers provide content, source, * contexts, metadata (including explicit `undefined`), target, and wakeup. * The union excludes attached contexts from non-waking next-step injection. @@ -201,7 +201,7 @@ export interface Agent { * @param options - source, attached contexts, and durable model-hidden meta. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - send(content: ContentBlock[], options?: SendOptions): AgentMessageId + followup(content: ContentBlock[], options?: SendOptions): AgentMessageId /** * Queue an ordinary message without waking an idle driver. The item retains @@ -251,7 +251,7 @@ export interface Agent { * @param input - the resolved content, attribution, context, metadata, and routing facts. * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. */ - acceptInput(input: ResolvedAgentInput): AgentMessageId + send(input: ResolvedAgentInput): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -306,7 +306,7 @@ declare module 'cordis' { * FIFO). Source defaults are already applied, so `message` holds the exact * accepted values. This is the enqueue-time live signal; the durable record * is the eventual `user/message`/`steering/message`. Injection through - * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs + * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs * and does not emit this. * @param agent - the agent whose inbox received the item. * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 9bdb634a2c..b5b0424957 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -28,11 +28,11 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent { session: new Session(id), status: 'idle', ctx: new Context(), - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, ...overrides, @@ -50,7 +50,7 @@ describe('AgentRegistry', () => { expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>() expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>() expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf<false>() - expectTypeOf<Parameters<Agent['acceptInput']>[0]>().toEqualTypeOf<ResolvedAgentInput>() + expectTypeOf<Parameters<Agent['send']>[0]>().toEqualTypeOf<ResolvedAgentInput>() expectTypeOf<OptionalInputKey>().toEqualTypeOf<never>() expectTypeOf<Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>['contexts']>() .toEqualTypeOf<[]>() diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 1ac506a098..e00bf2016d 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -235,7 +235,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'recover' }]) + handle.agent.followup([{ type: 'text', text: 'recover' }]) await waitForIdle(ctx, handle.agent) expect(adapter.requests).toBe(2) @@ -335,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) const agent = handle.agent - agent.send([{ type: 'text', text: 'hi' }]) + agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, agent) const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') @@ -364,7 +364,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'hi' }]) + handle.agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, handle.agent) expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) @@ -454,7 +454,7 @@ describe('dsh-agent-spine-demo bundle', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'hi' }]) + handle.agent.followup([{ type: 'text', text: 'hi' }]) await waitForIdle(ctx, handle.agent) expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 66ce345520..e6672f91ad 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -290,7 +290,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise try { /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition - agent.send([{ type: 'text', text: options.task }]) + agent.followup([{ type: 'text', text: options.task }]) } await turnEnded } finally { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 165a084d52..c57dda5b00 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -471,7 +471,7 @@ describe('runOneShot and executeCli', () => { startup.ctx.on('session/event', (session, event) => { if (session === startup.agent.session && event.type === 'assistant/chunk') started() }) - startup.agent.send([{ type: 'text', text: 'first' }]) + startup.agent.followup([{ type: 'text', text: 'first' }]) await running const startupAbort = new AbortController() const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal }) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 472b32f47a..b768813b22 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -36,7 +36,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => // (config.cwd = workdir) is the workspace. const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.send([{ type: 'text', text: + agent.followup([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' + 'Then read it back, then edit it to replace the literal word draft with final. ' + 'Tell me when done.' }]) @@ -68,7 +68,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => meta: { cwd: sessionDir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.send([{ type: 'text', text: + handle.agent.followup([{ type: 'text', text: 'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }]) await waitForIdle(ctx, handle.agent) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 48c92293c8..a958659eba 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -48,11 +48,11 @@ function stubAgent(id: string): { agent: Agent; session: Session } { session, ctx: new Context(), get status() { return status }, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() { status = 'idle' }, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index a4883a0f7c..67cbd89d05 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -219,7 +219,7 @@ export function apply(ctx: Context): void { } state.attempt = reservation try { - agent.send(content, { + agent.followup(content, { source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, }) } catch (error: unknown) { diff --git a/packages/goal/goal-session/src/prompt.ts b/packages/goal/goal-session/src/prompt.ts index 9a2f69fcd8..d98f0bb83a 100644 --- a/packages/goal/goal-session/src/prompt.ts +++ b/packages/goal/goal-session/src/prompt.ts @@ -7,7 +7,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' * Render the complete goal-round instruction retained in session history. * @param goal - exact active goal revision being admitted. * @param round - next positive round number. - * @returns a fresh one-block prompt for `Agent.send()`. + * @returns a fresh one-block prompt for `Agent.followup()`. */ export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] { return [{ diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 88f8ff202b..dc3d6723a7 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -276,7 +276,7 @@ describe('same-session goal driving', () => { ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) : next()) test.ctx.on('goal/changed', (agent, change) => { - if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }]) + if (change.operation === 'block') agent.followup([{ type: 'text', text: 'inspect the blocker' }]) }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) @@ -323,7 +323,7 @@ describe('same-session goal driving', () => { it('lets already-queued human work finish before reserving the next round', async () => { const test = await harness([textResponse('human answer'), textResponse('goal answer')]) test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) - test.agent.send([{ type: 'text', text: 'human goes first' }]) + test.agent.followup([{ type: 'text', text: 'human goes first' }]) await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') @@ -364,7 +364,7 @@ describe('same-session goal driving', () => { test.ctx.on('agent/inbox/enqueue', (agent, info) => { if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return inserted = true - agent.send([{ type: 'text', text: 'human joined the pending batch' }]) + agent.followup([{ type: 'text', text: 'human joined the pending batch' }]) }) test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) @@ -479,16 +479,16 @@ describe('same-session goal driving', () => { expect(injectedTurn).toBeGreaterThan(goalTurn) }) - it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { + it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => { const test = await harness([]) - // Reject only the goal-sourced round send, not the state-change injection + // Reject only the goal-sourced round follow-up, not the state-change injection // that precedes it. - const realSend = test.agent.send.bind(test.agent) - vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + const realFollowup = test.agent.followup.bind(test.agent) + vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => { if (options?.source?.kind === 'goal') { throw new Error('queue rejected') } - return realSend(content, options) + return realFollowup(content, options) }) test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) @@ -502,15 +502,15 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('preserves a custom agent side effect when send disarms before throwing', async () => { + it('preserves a custom agent side effect when followup disarms before throwing', async () => { const test = await harness([]) - const realSend = test.agent.send.bind(test.agent) - vi.spyOn(test.agent, 'send').mockImplementation((content, options) => { + const realFollowup = test.agent.followup.bind(test.agent) + vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => { if (options?.source?.kind === 'goal') { test.ctx.goals.disarm(test.agent) throw new Error('queue rejected after disarm') } - return realSend(content, options) + return realFollowup(content, options) }) test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) @@ -609,7 +609,7 @@ describe('same-session goal driving', () => { it('blocks forged goal attribution without touching an absent reservation', async () => { const test = await harness([]) - test.agent.send([{ type: 'text', text: 'forged automatic work' }], { + test.agent.followup([{ type: 'text', text: 'forged automatic work' }], { source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 }, }) await test.agent.whenIdle() @@ -621,7 +621,7 @@ describe('same-session goal driving', () => { it('does not invent goal state when ordinary queued work is cancelled', async () => { const test = await harness([]) - test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) + test.agent.followup([{ type: 'text', text: 'cancel ordinary work' }]) test.agent.cancel({ kind: 'user' }) await test.agent.whenIdle() @@ -631,7 +631,7 @@ describe('same-session goal driving', () => { it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => { const test = await harness(['hang']) - test.agent.send([{ type: 'text', text: 'inspect something first' }]) + test.agent.followup([{ type: 'text', text: 'inspect something first' }]) await waitForRequests(test.adapter, 1) const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 94c90cf192..264261d94b 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -64,7 +64,7 @@ function stubAgentForSession(session: Session): StubAgent { session, ctx: new Context(), get status() { return status }, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content, options) { @@ -72,7 +72,7 @@ function stubAgentForSession(session: Session): StubAgent { else appendInjection(session, content, options) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 3f286f8ec3..6e0b25c567 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. -`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. +`{ kind: 'user' }` is a host attestation. `Agent.followup()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately. diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 41fe713dc6..f48c9cd098 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE /** * Whether host-attested human input appears in the current root-agent turn. - * An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human + * An omitted `Agent.followup()` / `steer()` source resolves to `user`, so non-human * producers must supply their own source rather than inheriting this authority. */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 3aaa06c447..bb795901ba 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -31,7 +31,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { session, get status() { return status }, ctx: new Context(), - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject(content: ContentBlock[], options?: InjectOptions) { @@ -43,7 +43,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { }, { surfaceOp: 'append' }) return AgentMessageId('stub') }, - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 9f0247da72..d0a3214518 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -56,7 +56,7 @@ describe('threshold escalation', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -77,7 +77,7 @@ describe('threshold escalation', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -99,7 +99,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -123,7 +123,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) @@ -141,7 +141,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -162,7 +162,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -178,7 +178,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded @@ -194,7 +194,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically @@ -215,8 +215,8 @@ describe('chain semantics', () => { ])) const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) - agentA.send([{ type: 'text', text: 'go' }]) - agentB.send([{ type: 'text', text: 'go' }]) + agentA.followup([{ type: 'text', text: 'go' }]) + agentB.followup([{ type: 'text', text: 'go' }]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry @@ -234,9 +234,9 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'again' }]) + agent.followup([{ type: 'text', text: 'again' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(0) @@ -256,13 +256,13 @@ describe('chain semantics', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - first.send([{ type: 'text', text: 'go' }]) + first.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() await first.whenIdle() const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) - second.send([{ type: 'text', text: 'go' }]) + second.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, second) expect(reminders(second)).toHaveLength(0) @@ -278,7 +278,7 @@ describe('chain semantics', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(1) @@ -294,7 +294,7 @@ describe('chain semantics', () => { textResponse('done'), ])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(reminders(agent)).toHaveLength(0) @@ -316,7 +316,7 @@ describe('fold onto the downstream decision', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) @@ -347,7 +347,7 @@ describe('fold onto the downstream decision', () => { ]) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const found = reminders(agent) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 6f829f8c82..f5fd5702d5 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -97,7 +97,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'do something' }]) + agent.followup([{ type: 'text', text: 'do something' }]) await waitForIdle(ctx, agent) // The prompt was blocked: model never called, turn ended rejected. @@ -120,7 +120,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The injected context reached the model and is recorded with the plugin source. @@ -145,7 +145,7 @@ describe('hooks-claude bridge — PreToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'use danger' }]) + agent.followup([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) expect(ran).toBe(false) @@ -168,7 +168,7 @@ describe('hooks-claude bridge — PreToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'use safe' }]) + agent.followup([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -190,7 +190,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -211,7 +211,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const log = events(agent) @@ -235,7 +235,7 @@ describe('hooks-claude bridge — PostToolUse', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. @@ -264,7 +264,7 @@ describe('hooks-claude bridge — SessionStart', () => { // fixed sleep that flakes under load. await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs') @@ -357,7 +357,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. expect(adapter.requests).toHaveLength(1) @@ -379,7 +379,7 @@ describe('hooks-claude bridge — load resilience', () => { await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 82461dcaa5..ab770479ed 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -74,7 +74,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }, @@ -104,7 +104,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.logger.warn = warn as never ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran }) @@ -120,7 +120,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let sawArgs: unknown ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. expect((sawArgs as { command?: string }).command).toBe('original') @@ -136,7 +136,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt proceeded unchanged; no injected context. expect(adapter.requests).toHaveLength(1) @@ -166,7 +166,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -191,7 +191,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -207,7 +207,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') @@ -223,7 +223,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. expect(adapter.requests).toHaveLength(2) @@ -271,7 +271,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -285,7 +285,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) @@ -314,7 +314,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') @@ -329,7 +329,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. expect(ran).toBe(false) @@ -344,7 +344,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -369,7 +369,7 @@ export function defineCoverageCases(group: CoverageGroup): void { HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) }) @@ -384,7 +384,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) const res = events(agent).find(e => e.type === 'hook/result') @@ -399,7 +399,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -418,7 +418,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -435,7 +435,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -455,7 +455,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran }) @@ -473,7 +473,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(events(handle.agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) @@ -491,7 +491,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // A later listener that blocks every prompt (registered AFTER the bridge). ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // the downstream block won: the model was never called, no user/message was // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` @@ -519,7 +519,7 @@ export function defineCoverageCases(group: CoverageGroup): void { }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -547,7 +547,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) @@ -570,7 +570,7 @@ export function defineCoverageCases(group: CoverageGroup): void { }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') @@ -593,7 +593,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -617,7 +617,7 @@ export function defineCoverageCases(group: CoverageGroup): void { bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -640,7 +640,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await waitFor(() => threw) expect(threw).toBe(true) agent.inject = original - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject }) @@ -667,7 +667,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir @@ -717,7 +717,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) // Not surfaced: the systemMessage text never reaches the model request. @@ -736,7 +736,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 00c0e6c14d..923a4bf8b5 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -77,7 +77,7 @@ describe('hooks-codex bridge', () => { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'run ls' }]) + agent.followup([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) expect(ran).toBe(false) @@ -98,7 +98,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -115,7 +115,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'cancel the hook' }]) + agent.followup([{ type: 'text', text: 'cancel the hook' }]) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) @@ -139,7 +139,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) }) @@ -149,7 +149,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) }) @@ -169,7 +169,7 @@ describe('hooks-codex bridge', () => { await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 04602b3792..ebb2164902 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -65,7 +65,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }, @@ -84,7 +84,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) const te = events(agent).findLast(e => e.type === 'turn/end') expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') @@ -96,7 +96,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -109,7 +109,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) const te = events(agent).findLast(e => e.type === 'turn/end') @@ -131,7 +131,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') expect(req).toContain('from-downstream') @@ -154,7 +154,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) @@ -175,7 +175,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro }], })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([ @@ -193,7 +193,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) @@ -208,7 +208,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') }) @@ -219,7 +219,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) @@ -232,7 +232,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) }) @@ -246,7 +246,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -257,7 +257,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) @@ -270,7 +270,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis @@ -293,7 +293,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') }) @@ -316,7 +316,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) }) @@ -329,7 +329,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -344,7 +344,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false) }) @@ -370,7 +370,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -383,7 +383,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) }) @@ -399,7 +399,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) @@ -412,7 +412,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) }) @@ -424,7 +424,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) @@ -441,7 +441,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') }) @@ -477,7 +477,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) }) @@ -493,7 +493,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') }) @@ -506,7 +506,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -534,7 +534,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') }) @@ -547,7 +547,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') }) @@ -559,7 +559,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -574,7 +574,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') expect(payload.tool_input.command).toBe('ls') @@ -590,7 +590,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) }) @@ -602,7 +602,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') }) @@ -625,7 +625,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..f4d7bb2c1a 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -437,7 +437,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { if (mode === 'steer') agent.steer(content, { source }) - else agent.send(content, { source }) + else agent.followup(content, { source }) } catch (error: unknown) { // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..d86cb2ee5a 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -372,7 +372,7 @@ describe('sessions.prompt / cancel', () => { const { api, ctx } = running const { sessionId } = expectOk(await api.sessions.create(request({}))) const agent = ctx.agents.get(sessionId) as Agent - agent.send([{ type: 'text', text: 'run forever' }]) + agent.followup([{ type: 'text', text: 'run forever' }]) expectOk(await api.sessions.cancel(request({ sessionId }))) const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId })) @@ -391,7 +391,7 @@ describe('sessions.history', () => { const { sessionId } = expectOk(await first.api.sessions.create(request({}))) const agent = first.ctx.agents.get(sessionId) as Agent const idle = waitForIdle(first.ctx, agent) - agent.send([{ type: 'text', text: 'save me' }]) + agent.followup([{ type: 'text', text: 'save me' }]) await idle const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title') await first.dispose() @@ -440,7 +440,7 @@ describe('sessions.history', () => { const agent = ctx.agents.get(sessionId) as Agent for (const text of ['q1', 'q2', 'q3']) { const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text }]) + agent.followup([{ type: 'text', text }]) await idle } @@ -511,7 +511,7 @@ describe('events streams', () => { const agent = ctx.agents.get(sessionId) as Agent const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle const live = await stream.next() expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event') @@ -574,7 +574,7 @@ describe('events streams', () => { const agent = ctx.agents.get(sessionId) as Agent const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'run' }]) + agent.followup([{ type: 'text', text: 'run' }]) await idle const runningFrame = await stream.next() expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 1aafc91d92..bb08577d74 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -114,7 +114,7 @@ describe('real Loader composition', () => { loaded.llm.registerAdapter(['mock'], adapter) const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(loaded, agent) - agent.send([{ type: 'text', text: 'recover' }]) + agent.followup([{ type: 'text', text: 'recover' }]) await idle expect(adapter.requests).toBe(2) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 4dc1c06bd6..20e7862ebc 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -129,7 +129,7 @@ describe('bounded transient retry policy', () => { }) }) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) const event = await scheduled expect(event.data).toEqual({ @@ -178,7 +178,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(500) @@ -213,7 +213,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) const first = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) expect((await first).data.delayMs).toBe(450) const second = waitForRetry(context, agent, 2) @@ -246,7 +246,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) expect((await scheduled).data.delayMs).toBe(0) const idle = waitForIdle(context, agent) @@ -264,7 +264,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, acceptedAgent, 1) - acceptedAgent.send([{ type: 'text', text: 'go' }]) + acceptedAgent.followup([{ type: 'text', text: 'go' }]) expect((await scheduled).data.delayMs).toBe(2_000) const acceptedIdle = waitForIdle(context, acceptedAgent) await vi.advanceTimersByTimeAsync(2_000) @@ -278,7 +278,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(rejected)) const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) const rejectedIdle = waitForIdle(context, rejectedAgent) - rejectedAgent.send([{ type: 'text', text: 'go' }]) + rejectedAgent.followup([{ type: 'text', text: 'go' }]) await rejectedIdle expect(rejected.requests).toHaveLength(1) expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) @@ -290,7 +290,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(adapter)) const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) @@ -307,7 +307,7 @@ describe('bounded transient retry policy', () => { context = mounted.ctx const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) @@ -335,7 +335,7 @@ describe('bounded transient retry policy', () => { model: 'mock', }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await entered.promise const disposing = mounted.retryFiber.dispose() @@ -376,7 +376,7 @@ describe('bounded transient retry policy', () => { model: 'mock', }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await captured.promise await mounted.retryFiber.dispose() @@ -397,7 +397,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(adapter)) const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) agent.cancel({ kind: 'user' }) @@ -426,7 +426,7 @@ describe('bounded transient retry policy', () => { const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) @@ -450,7 +450,7 @@ describe('bounded transient retry policy', () => { }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle expect(adapter.requests).toHaveLength(1) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 81aa7203c8..e195ed54b2 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -75,7 +75,7 @@ describe('plan mode through the agent loop', () => { // the first prompt-submit, BEFORE the first assembly. ctx.planMode.set(agent, true) - agent.send([{ type: 'text', text: 'explore the repo' }]) + agent.followup([{ type: 'text', text: 'explore the repo' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -103,14 +103,14 @@ describe('plan mode through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'hello' }]) + agent.followup([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) expect(foldPlanMode(agent.session.events)).toBe(false) const first = findEvent(agent.session.events, 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) ctx.planMode.set(agent, true) - agent.send([{ type: 'text', text: 'now plan' }]) + agent.followup([{ type: 'text', text: 'now plan' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -146,7 +146,7 @@ describe('plan mode through the agent loop', () => { }) const idle = waitForIdle(ctx, agent) - agent.send([{ type: 'text', text: 'plan after the transient failure' }]) + agent.followup([{ type: 'text', text: 'plan after the transient failure' }]) await recoveryEntered.promise ctx.planMode.set(agent, true) releaseRecovery.resolve(true) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index f32dca3df6..26f4ddcc11 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers<undefined>() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 37e933d827..763ab5c871 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 09799ccecd..42622f2712 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -27,11 +27,11 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle', ctx: scopeFiber.ctx, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index a0ec66aff9..40477aeb7a 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index c974ad90db..e0b854ee43 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts index 89cd254c8e..412ff8203d 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -56,5 +56,5 @@ const handle = await ctx.agents.create({ sessionId: SessionId('semantic-checkpoint-crash'), agentOptions: { provider: 'crash', model: 'crash' }, }) -handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }]) +handle.agent.followup([{ type: 'text', text: 'exercise the crash boundary' }]) await waitForCrash() diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 6f091c2bf6..6882d324fc 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { ]) // Parent does one real turn first, so the fork has a completed turn to seed. - parent.send([{ type: 'text', text: 'parent q1' }]) + parent.followup([{ type: 'text', text: 'parent q1' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { await forkRun.dispose() // The parent is unaffected and keeps working after both delegations. - parent.send([{ type: 'text', text: 'parent q2' }]) + parent.followup([{ type: 'text', text: 'parent q2' }]) await parent.whenIdle() const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two') diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 046c6ee8fe..cf5848e625 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => { it('seeds every completed parent turn through the last turn/end', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) - parent.send([{ type: 'text', text: 'q1' }]) + parent.followup([{ type: 'text', text: 'q1' }]) await parent.whenIdle() - parent.send([{ type: 'text', text: 'q2' }]) + parent.followup([{ type: 'text', text: 'q2' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => { // Parent runs one turn, then we fork. The child's seeded log should contain // the parent's first turn, and the child should run its own new turn on top. const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const parentPrefixLen = parent.session.events.length @@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => { // open (a hanging model call), and fork while it's in flight. The seed must stop after the // balanced first turn; including the open turn would fail invariant replay during start. const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) - parent.send([{ type: 'text', text: 'q1' }]) + parent.followup([{ type: 'text', text: 'q1' }]) await parent.whenIdle() // Start a second turn that hangs (open turn/start + open step, never ends). - parent.send([{ type: 'text', text: 'q2' }]) + parent.followup([{ type: 'text', text: 'q2' }]) await new Promise(r => setTimeout(r, 20)) // let the hanging turn open // Forking now must NOT throw (the open second turn is excluded from the seed). @@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => { textResponse('parent turn'), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), ]) - parent.send([{ type: 'text', text: 'warm up' }]) + parent.followup([{ type: 'text', text: 'warm up' }]) await parent.whenIdle() const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'report structured' }], @@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => { // `readResult` must scan only child-owned events after the seed. The child emits no assistant // message, so scanning the whole log would incorrectly return the parent's distinctive text. const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 7776b2af3f..48c72e9cf0 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -11,7 +11,7 @@ The driver follows this sequence: 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. -4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`. +4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. 5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns. The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index e83b397bb5..71bc7e42a6 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -141,7 +141,7 @@ export async function startInProcessRun( const result: Promise<SubagentResult> = (async () => { try { - child.send(request.prompt) + child.followup(request.prompt) await child.whenIdle() return readResult( child, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 1c08488fc2..bb4cf32544 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -522,7 +522,7 @@ describe('in-process structured output', () => { textResponse('parent answer'), toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) @@ -538,7 +538,7 @@ describe('in-process structured output', () => { describe('scoped registration (each child owns its capture tool)', () => { it('a plain agent never sees the tool: nothing is registered globally at all', async () => { const { ctx, parent, adapter } = await setup([textResponse('parent answer')]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() // Scoped registration: the global view has no capture tool, ever. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() @@ -552,7 +552,7 @@ describe('in-process structured output', () => { // Child turn: must see it, with the run's schema. toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }), ]) - parent.send([{ type: 'text', text: 'hello' }]) + parent.followup([{ type: 'text', text: 'hello' }]) await parent.whenIdle() expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) @@ -630,7 +630,7 @@ describe('in-process structured output', () => { it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { const { parent, adapter } = await setup([textResponse('plain')]) - parent.send([{ type: 'text', text: 'q' }]) + parent.followup([{ type: 'text', text: 'q' }]) await parent.whenIdle() const request = adapter.requests[0]! expect(request.tools).toBeUndefined() diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index a1401bbb6d..ce91480ea8 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -87,7 +87,7 @@ describe('startInProcessRun', () => { it('seeds a forked child but reads only the child-owned output', async () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) - parent.send([{ type: 'text', text: 'parent question' }]) + parent.followup([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() const seed = parent.session.events.slice() const run = await startInProcessRun(request(parent), { seed }) diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 89efb2b815..8fbecdc976 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -31,7 +31,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( ctx = await spawnHarness(workdir) const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - parent.send([{ type: 'text', text: + parent.followup([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." ' + 'After the subagent finishes, tell me it is done.' }]) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 9fcdbee5e7..c83eca75ab 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -95,7 +95,7 @@ describe('dsh-subagent-spawn', () => { it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => { // Drive the parent through one real turn so it has history, THEN spawn. const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) - parent.send([{ type: 'text', text: 'parent prompt' }]) + parent.followup([{ type: 'text', text: 'parent prompt' }]) await parent.whenIdle() const parentEventCount = parent.session.events.length expect(parentEventCount).toBeGreaterThan(0) @@ -372,7 +372,7 @@ describe('dsh-subagent-spawn', () => { textResponse('parent answer'), textResponse('child answer'), ]) - parent.send([{ type: 'text', text: 'hi' }]) + parent.followup([{ type: 'text', text: 'hi' }]) await parent.whenIdle() const run = await start(ctx, 'spawn', { diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index e06fe01aa5..015f1c4f2b 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -23,11 +23,11 @@ function stubAgent(ctx: Context, rawId: string): Agent { session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, - send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 869c1183f2..fe09af9730 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -59,7 +59,7 @@ describe('todo_write tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'plan a two-step task' }]) + agent.followup([{ type: 'text', text: 'plan a two-step task' }]) await waitForIdle(ctx, agent) const log = agent.session.events @@ -87,7 +87,7 @@ describe('todo_write tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) - agent.send([{ type: 'text', text: 'plan then update' }]) + agent.followup([{ type: 'text', text: 'plan then update' }]) await waitForIdle(ctx, agent) const todoEvents = agent.session.events.filter(e => e.type === 'todo/write') diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3a8576df74..f135e42553 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -29,7 +29,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | | `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands | | `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors | -| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | +| `session/prompt` | `ctx.commands.execute()` or `agent.followup()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC | | `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another | | `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 55d44613c5..3292ee17c4 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -23,7 +23,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | | `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | -| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. | +| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.followup`. One request is in flight per session. | | `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. | | `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). | | `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. | diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 3330c582b4..dcc32f1c6f 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1056,7 +1056,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } const { text } = referencedPrompt let preparedContent: ContentBlock[] = [{ type: 'text', text }] - let preparedContexts: NonNullable<Parameters<Agent['send']>[1]>['contexts'] = [] + let preparedContexts: NonNullable<Parameters<Agent['followup']>[1]>['contexts'] = [] if (referencedPrompt.references.length > 0) { const sessionReferences = ctx.get('sessionReferences') if (sessionReferences === undefined) { @@ -1081,14 +1081,14 @@ export function apply(ctx: Context, config: AcpConfig): void { } assertOpen() } - // Install the in-flight slot BEFORE send() (send does not synchronously + // Install the in-flight slot BEFORE followup() (followup does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the // A turn that ends in error rejects this promise (the codec never // produces an error stop reason). const stopReason = await new Promise<StopReason>((resolve, reject) => { rec.inflight = { resolve, reject, turn: undefined } - rec.agent.send(preparedContent, { contexts: preparedContexts }) + rec.agent.followup(preparedContent, { contexts: preparedContexts }) }) return { stopReason } }, diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 8baf076d58..bcfb177b16 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -264,7 +264,7 @@ describe('acp bridge — disposal & HMR safety', () => { const handle = await harness.ctx.agents.create({ sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() @@ -288,7 +288,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Drive a turn that hangs in the model stream, so the loop is mid-turn when // disposed — its exit runs a final session/flush we can gate to hold the // teardown observably in-flight. - handle.agent.send([{ type: 'text', text: 'go' }]) + handle.agent.followup([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') let releaseFlush!: () => void diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index fdbaf241e6..ac85fbd7e0 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -30,7 +30,7 @@ describe('acp bridge — demux & config edges', () => { const before = harness.updates.length const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) - foreign.send([{ type: 'text', text: 'hi' }]) + foreign.followup([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 65ca3ed52c..5200a2de13 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -152,7 +152,7 @@ export class HarnessSdkServer { rec.activePrompt = true try { rec.lastTurnEnd = undefined - rec.handle.agent.send(params.contentBlocks) + rec.handle.agent.followup(params.contentBlocks) await rec.handle.agent.whenIdle() const status = this.finishedStatus(rec.lastTurnEnd) this.transport.notify('session.finished', { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 5105023581..43dc690c72 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -152,7 +152,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'dsagent-model' }, }) - orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }]) + orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }]) await orphanHandle.agent.whenIdle() await orphanHandle.dispose() expect(llmServer.requests).toHaveLength(3) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 21601fa280..2951ce612b 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -18,9 +18,9 @@ Before model output, session events, tool presenters, questions, configuration, Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed. -When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. +When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -77,7 +77,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic #### What the model sees -Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`. +Each non-empty ordinary editor submission becomes one text block, sent with `agent.followup()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`. #### Token effect @@ -125,7 +125,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr #### What the model sees -A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. +A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. #### Token effect diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index aef8ffc403..a97754d8cb 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2524,7 +2524,7 @@ export function createTuiChat( } else if (agent.status === 'running') { agent.steer(content, { contexts }) } else { - agent.send(content, { contexts }) + agent.followup(content, { contexts }) } } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6bf6fcb20d..a111621e34 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -150,7 +150,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e steered, steeredOptions, cancelled, - send(content, options) { + followup(content, options) { sent.push(content) sentOptions.push(options) return AgentMessageId('stub') @@ -166,7 +166,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e return AgentMessageId('stub') }, inject: () => AgentMessageId('stub'), - acceptInput: () => AgentMessageId('stub'), + send: () => AgentMessageId('stub'), cancel(cause = { kind: 'user' }) { cancelled.push(cause) }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 623f85a3d4..725369f3ca 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2499,7 +2499,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) @@ -2523,7 +2523,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -2557,14 +2557,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -2594,7 +2594,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -2636,7 +2636,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', ctx, - send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index dcc3b947fb..c9492b9450 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -70,7 +70,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { agentOptions: { provider: 'mock', model: 'mock' }, }) const parent = parentHandle.agent - parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) + parent.followup([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) await parent.whenIdle() const children: Agent[] = [] diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 7c61b9395d..5f2080b149 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -30,8 +30,45 @@ function quote(value: string): string { } /** - * Collect exported interface and type shapes; omit names declared in multiple - * packages rather than risk serving the wrong package's shape. + * Reduce an exported class to its type shape: drop method/constructor bodies + * and property initializers so the catalog serves member signatures, not + * implementation. + */ +function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration { + const isNonPublic = (member: ts.ClassElement): boolean => + (ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m => + m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false + const members = node.members.flatMap((member): ts.ClassElement[] => { + if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return [] + if (ts.isMethodDeclaration(member)) { + return [ts.factory.updateMethodDeclaration( + member, member.modifiers, member.asteriskToken, member.name, member.questionToken, + member.typeParameters, member.parameters, member.type, undefined)] + } + if (ts.isConstructorDeclaration(member)) { + return [ts.factory.updateConstructorDeclaration(member, member.modifiers, member.parameters, undefined)] + } + if (ts.isGetAccessorDeclaration(member)) { + return [ts.factory.updateGetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, member.type, undefined)] + } + if (ts.isSetAccessorDeclaration(member)) { + return [ts.factory.updateSetAccessorDeclaration( + member, member.modifiers, member.name, member.parameters, undefined)] + } + if (ts.isPropertyDeclaration(member)) { + return [ts.factory.updatePropertyDeclaration( + member, member.modifiers, member.name, member.questionToken ?? member.exclamationToken, member.type, undefined)] + } + return [member] + }) + return ts.factory.updateClassDeclaration( + node, node.modifiers, node.name, node.typeParameters, node.heritageClauses, members) +} + +/** + * Collect exported interface, type-alias, and body-stripped class shapes; omit + * names declared in multiple packages rather than serve the wrong shape. */ function collectTypeDecls(scanRoot: string = root): Map<string, string> { const printer = ts.createPrinter({ removeComments: true }) @@ -41,14 +78,16 @@ function collectTypeDecls(scanRoot: string = root): Map<string, string> { const abs = resolve(scanRoot, rel) const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { - if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue + const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) + if (!named || stmt.name === undefined) continue if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue const name = stmt.name.text if (decls.has(name)) { ambiguous.add(name) continue } - const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '') + const emit = ts.isClassDeclaration(stmt) ? classShape(stmt) : stmt + const printed = printer.printNode(ts.EmitHint.Unspecified, emit, sf).replace(/\r/g, '') decls.set(name, printed.length > MAX_DECL_CHARS ? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */` : printed) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 26ebfa8db7..1f54f7358f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -918,7 +918,7 @@ function renderLifecycle(): string { ' participant Session', ' participant Persistence', ' participant SDK as UI or SDK listener', - ' User->>Agent: send(content)', + ' User->>Agent: followup(content)', ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`, ' Agent->>Driver: queued work wakes driver', ` Driver-->>SDK: ${mermaidCode('agent/status')} running`, From 8e84aa62185cb3d88404c90f39e61e4656c46fe6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:10:55 +0800 Subject: [PATCH 084/113] docs(i18n): normalize Agent Note terminology --- ...06-18-agent-lifecycle-and-ownership-seams.i18n.yaml | 2 +- ...026-06-18-agent-lifecycle-and-ownership-seams.zh.md | 6 +++--- ...6-18-shared-persistence-write-coordinator.i18n.yaml | 2 +- ...26-06-18-shared-persistence-write-coordinator.zh.md | 4 ++-- ...6-06-20-generic-long-running-tool-runtime.i18n.yaml | 2 +- .../2026-06-20-generic-long-running-tool-runtime.zh.md | 4 ++-- .../2026-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 2 +- .../2026-07-07-tool-call-timeout-policy.i18n.yaml | 2 +- .../2026-07-07-tool-call-timeout-policy.zh.md | 4 ++-- .../2026-07-08-agent-scope-contexts.i18n.yaml | 2 +- .../architecture/2026-07-08-agent-scope-contexts.zh.md | 2 +- .../2026-06-14-acp-agent-client-protocol.i18n.yaml | 2 +- .../feature/2026-06-14-acp-agent-client-protocol.zh.md | 4 ++-- .../implemented/feature/2026-06-15-code-mode.i18n.yaml | 2 +- .../implemented/feature/2026-06-15-code-mode.zh.md | 6 +++--- .../2026-06-21-subagent-capability-seam.i18n.yaml | 2 +- .../feature/2026-06-21-subagent-capability-seam.zh.md | 2 +- .../feature/2026-07-05-dynamic-workflows.i18n.yaml | 2 +- .../feature/2026-07-05-dynamic-workflows.zh.md | 4 ++-- .../implemented/feature/2026-07-06-sandbox.i18n.yaml | 2 +- .../notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- .../feature/2026-07-07-mcp-client-plugin.i18n.yaml | 2 +- .../feature/2026-07-07-mcp-client-plugin.zh.md | 2 +- ...026-07-08-self-referential-cordis-toolset.i18n.yaml | 2 +- .../2026-07-08-self-referential-cordis-toolset.zh.md | 4 ++-- .../process/2026-06-16-pnpm-over-yarn.i18n.yaml | 2 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 2 +- .../2026-06-20-core-data-structures-catalog.i18n.yaml | 2 +- .../2026-06-20-core-data-structures-catalog.zh.md | 2 +- .../2026-06-20-generated-cordis-catalog.i18n.yaml | 2 +- .../process/2026-06-20-generated-cordis-catalog.zh.md | 2 +- .../process/2026-07-02-tool-schema-catalog.i18n.yaml | 2 +- .../process/2026-07-02-tool-schema-catalog.zh.md | 2 +- .../process/2026-07-04-doc-tiers-and-budgets.i18n.yaml | 2 +- .../process/2026-07-04-doc-tiers-and-budgets.zh.md | 2 +- .../2026-07-04-persistence-log-catalog.i18n.yaml | 2 +- .../process/2026-07-04-persistence-log-catalog.zh.md | 2 +- .../2026-06-20-public-agent-stop-surface.i18n.yaml | 2 +- .../2026-06-20-public-agent-stop-surface.zh.md | 10 +++++----- ...06-20-remove-agent-boundary-mirror-events.i18n.yaml | 2 +- ...026-06-20-remove-agent-boundary-mirror-events.zh.md | 2 +- .../2026-06-20-unify-agent-and-session-id.i18n.yaml | 2 +- .../2026-06-20-unify-agent-and-session-id.zh.md | 4 ++-- .../2026-07-02-remove-stream-chunk-mirror.i18n.yaml | 2 +- .../2026-07-02-remove-stream-chunk-mirror.zh.md | 2 +- .../2026-06-16-typed-event-schemas.i18n.yaml | 2 +- .../architecture/2026-06-16-typed-event-schemas.zh.md | 4 ++-- .../2026-07-04-prune-dead-core-spine-surface.i18n.yaml | 2 +- .../2026-07-04-prune-dead-core-spine-surface.zh.md | 4 ++-- ...7-12-collapse-workflow-to-foreground-core.i18n.yaml | 2 +- ...26-07-12-collapse-workflow-to-foreground-core.zh.md | 2 +- 52 files changed, 69 insertions(+), 69 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml index 943ac3761e..8c6edb72a9 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-agent-lifecycle-and-ownership-seams.md: 70ebf1c6de97cb14e27377ec1f9bac18fc0766a6 -2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 285c42fd8a8086b5a1ee0b9f79112b048099077a +2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 54d174aa325384f7c7492f072f10954f0b1d08c9 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md index 285c42fd8a..54d174aa32 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md @@ -14,11 +14,11 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se ### 1. 队列感知的 `Agent.cancel(cause?)` -`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在取得所有权前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会到达取消后的静默状态,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)拥有当前的 cause、signal 生命周期与协作式结算契约。 +`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在取得所有权前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)拥有当前的 cause、signal 生命周期与协作式结算契约。 ### 2. `AgentHandle` 异步释放器 -`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(真正的静默,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个会话的释放器,并在断连/拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)。 +`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个会话的释放器,并在断连/拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)。 **拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect(`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(`Promise.all`),这会让会话存储的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed` 和 `session/disposed` 通知无法拒绝该链或跳过后续拆除。 @@ -47,4 +47,4 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen ## 后果 -本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。简洁的同步 `Agent.send()` 人体工学得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。 +本变更有意触及公开接口(`Agent`、`AgentFactory`、bash seam),而非作为 ACP 的局部补丁。同步 `Agent.send()` 的简洁易用性得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index dcd225a162..b289d47edd 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-18-shared-persistence-write-coordinator.md: ea9c4fb74f7c1bd68fb62efedd3e1657da96ea65 -2026-06-18-shared-persistence-write-coordinator.zh.md: 55b6aca31cb75ff58f032ad49569e25b13baee53 +2026-06-18-shared-persistence-write-coordinator.zh.md: 3b4dd7b762c2f39a908eabe23e5d734981b5767b diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 55b6aca31c..3b4dd7b762 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -27,7 +27,7 @@ Status: implemented - `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 -- `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于静默排空之后被 await,因此 close 失败不会掩盖排空错误。 +- `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 ### 不透明的 torn marker @@ -44,4 +44,4 @@ Status: implemented ## 后果 -协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为静止状态边界。其钩子面保持窄小:标识校验、接管、碰撞检查与不修改状态的检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时,不会因提交中断 closers 而与新的存活所有者产生竞态。新后端只需实现存储原语,而无需复制立即写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker 和脱离会话生命周期的退役任务,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查与不修改状态的检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时,不会因提交中断 closers 而与新的存活所有者产生竞态。新后端只需实现存储原语,而无需复制立即写入生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 5e45d346e0..df140d462c 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generic-long-running-tool-runtime.md: 25668a6a699576e435670b9580385073f2f036fe -2026-06-20-generic-long-running-tool-runtime.zh.md: f6432b7d1f1bd4497c2bf186235bb4e3bb653ec7 +2026-06-20-generic-long-running-tool-runtime.zh.md: d6db17b441d965dfaed1847f0dcfa4fd6fc25bb4 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index f6432b7d1f..d6db17b441 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -47,7 +47,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 快照存储所有者的品牌化 `SessionId` 以供授权,生命周期操作则保留确切的实时 `Agent` 实例。这两种身份用途不同:会话相等性授予访问权,精确对象身份决定清理和完成通知的接收方。复用 agent 或会话 id,不能将旧作用域的清理或通知重定向到替代实例。 -某个所有者的第一个任务会向 `owner.ctx` 附加一个异步 effect。agent 作用域释放时会取消该所有者的实时任务、等待其终止记录,并移除其快照。该 effect 可跨生产方重载存续,并加入 agent 现有的停稳边界。任务服务保留 effect disposer,使服务重载可以在全局资源销毁后,从仍然存活的 agent 作用域中分离回调。 +某个所有者的第一个任务会向 `owner.ctx` 附加一个异步 effect。agent 作用域释放时会取消该所有者的实时任务、等待其终止记录,并移除其快照。该 effect 可跨生产方重载存续,并加入 agent 现有的完全停稳边界。任务服务保留 effect disposer,使服务重载可以在全局资源销毁后,从仍然存活的 agent 作用域中分离回调。 对于遵守契约的生产方,`AgentHandle.dispose()` 只在所属后台工作停止后解决。需要比 agent 存活更久的工作必须以无所有者方式启动;要跨运行时重启存续,则需另行设计持久任务。 @@ -89,7 +89,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 ## 生产方集成 -bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `BashProcess`,提供增量读取、取消、退出事实以及不拒绝的停稳 promise。本地执行器只为自身释放时能终止并等待进程而保留实时句柄。前台调用方继续直接使用 `resolve` 和 `run`。 +bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `BashProcess`,提供增量读取、取消、退出事实以及不拒绝的完全停稳 promise。本地执行器只为自身释放时能终止并等待进程而保留实时句柄。前台调用方继续直接使用 `resolve` 和 `run`。 对于后台 bash,`dsh-tool-bash` 将调用方 agent 注册为所有者。其钩子将 `kill()` 映射为取消,将 `done` 映射为 completed 或 killed 的 `TaskOutcome`,并将 `readOutput()` 映射为进程的有界增量输出,以及溢出文件与沙箱通知。通用任务工具拥有 id、状态行、列表、等待和完成通知。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 941a502c73..6d14977e92 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 -2026-07-06-timeout-deadline-library.zh.md: 6ac9d684582e04d5c7c21a74692fb6452a2846dd +2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 6ac9d68458..334914c689 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -110,6 +110,6 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): **每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得一个小型共享原语严格更优,因此成本/收益不同。 -**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到静止状态,而非仅仅请求它」的防御性规则一致。 +**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。 **保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。竞争的原因报告先到达的那个 abort,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index be5d4269fb..bd2426f377 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-tool-call-timeout-policy.md: 69fd1ee721de69621d3b57c10d960da0651b94dd -2026-07-07-tool-call-timeout-policy.zh.md: 8a92236b1b240e92785e2e7107bf9c7786906627 +2026-07-07-tool-call-timeout-policy.zh.md: c0dc56127cb983bd515db424d0ca37da9d0e978a diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index 8a92236b1b..c0dc56127c 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -71,7 +71,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { } ``` -这是一个协作式截止。它不会通过竞争工具 promise 来杀死任意工作;工具或其调用的能力必须遵循 `exec.signal` 并达到静止状态。因此声明 `timeoutMs` 意味着「此工具与 `exec.signal` 协作」,插件 README 将此作为其契约。 +这是一个协作式截止。它不会通过竞争工具 promise 来杀死任意工作;工具或其调用的能力必须遵循 `exec.signal` 并达到完全停稳。因此声明 `timeoutMs` 意味着「此工具与 `exec.signal` 协作」,插件 README 将此作为其契约。 无需新的会话事件来保证可重建性:`TOOL_TIMEOUT` 是该调用的最终面向模型的 `tool/result`,因此现有会话日志已经记录了下一次模型请求所见的内容和结构化 `{ name, code }` 错误。 @@ -109,6 +109,6 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { - `@deepseek-ai/dsh-tools` 在有意拆分 pre/post 工具钩子的拦截 seam 之后,获得了一个环绕分发的表面。其契约是狭窄的——包装注册表分发,而非替代 pre 门禁或 post 结果策略——且基础 `next()` 是带规范化的分发,因此包装器永远不会看到原始的工具抛出。 - 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 -- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。注册表会等待这一未达静止状态的工具体,而不是竞速它;同时插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 +- 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。注册表会等待这一未达完全停稳的工具体,而不是竞速它;同时插件契约声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 - 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。 - 与字面提案的偏差,按 implemented-Agent Note 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index ae6611d586..944a3fbc70 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 -2026-07-08-agent-scope-contexts.zh.md: 81decfe54c1a13ac9f8361fb98c94804bb6b80cc +2026-07-08-agent-scope-contexts.zh.md: 91ff75309e8327166900772992a16dbd371494fb diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 81decfe54c..91ff75309e 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -25,7 +25,7 @@ Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问 | 在哪里为某个 agent 注册行为? | 通过 `agent.ctx` 调用普通注册 API | | 某个 agent 的操作能看到什么? | 部署全局加上该 agent 的层,按所属服务的合并规则 | | 哪些作用域监听器会运行? | 无作用域监听器加上为该操作所属 agent 注册的监听器 | -| 该层存在多久? | setup 在发布前完成;dispose 保留该层直到工作达到静止 | +| 该层存在多久? | setup 在发布前完成;dispose 保留该层直到工作完全停稳 | 作用域是扁平的。解析永远不会遍历父级或兄弟作用域,生命周期所有权也不意味着注册继承。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml index 6ebcd37096..a3a3e3b72f 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml @@ -3,4 +3,4 @@ # 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: 180a199ec0d0fca5d9deba84d8c31ca63426c519 +2026-06-14-acp-agent-client-protocol.zh.md: 6bd234f2aa3f43f4fc6cbd921161253c46aab73e diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md index 180a199ec0..6bd234f2aa 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md @@ -30,7 +30,7 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本 桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述与自定义回答覆盖语义均被保留。 -生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的提示词,并行 dispose 所有 handle,等待循环静默与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。 +生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `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 是操作契约。 @@ -56,4 +56,4 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本 ## 验证 -ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的提示词结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放静默,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。 +ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的提示词结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放后的完全停稳,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 3731c58129..99c7cdeb5b 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -3,4 +3,4 @@ # 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: cbc164bb4e4de9752cae4161326cb52c7d841e8b +2026-06-15-code-mode.zh.md: c36a5a1566b60d1a990174ea268ca529ab1516d3 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index cbc164bb4e..c36a5a1566 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -44,7 +44,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 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` 结算后不允许子调用追加。 +3. **完全停稳后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 **子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 @@ -77,7 +77,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 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)。 +6. **dispose 至完全停稳**:服务自身的 dispose(资源释放)终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.md)。 ### 信任姿态 @@ -93,7 +93,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw ## 测试 -- **Worker 运行时:** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至静默。一个构建后包测试在纯 Node 下运行 worker 入口。 +- **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 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 7ecfa05391..92cff3d777 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47 -2026-06-21-subagent-capability-seam.zh.md: 9386b495a03611ecbf1d45e53c8ba0aa87eaa6f0 +2026-06-21-subagent-capability-seam.zh.md: 454e5c7859314a1f6706edf690eb79cbcb6cf5ee diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 9386b495a0..454e5c7859 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -38,7 +38,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 原语:异步 `start → SubagentRun` -提供方暴露 `start(request) → Promise<SubagentRun>`。完成时发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()`(资源释放)取消剩余工作并等待静止。启动失败时清理部分资源,不发出生命周期事件。`start` 与传输方式无关;`spawn` 仅指代全新的进程内后端。 +提供方暴露 `start(request) → Promise<SubagentRun>`。完成时发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()`(资源释放)取消剩余工作并等待完全停稳。启动失败时清理部分资源,不发出生命周期事件。`start` 与传输方式无关;`spawn` 仅指代全新的进程内后端。 ### 两类可选能力,两种发现方式 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index ce050c16c6..2b02053f05 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml @@ -3,4 +3,4 @@ # 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: 9ac94d491e14915841d55b6f6f7ad631a61a98f3 +2026-07-05-dynamic-workflows.zh.md: 757eafb5d21f42b2ca529ebeb6b16eb8205e823d diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 9ac94d491e..757eafb5d2 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -24,11 +24,11 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) ### 引擎(dsh-workflow-workerthread):每次运行一个 worker 线程 -**信任前提**:工作流脚本与模型的 bash 访问具有相同的信任级别。引擎容纳有缺陷的脚本,并保证结果已 settled、值为 JSON 安全、取消后静默;它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。 +**信任前提**:工作流脚本与模型的 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)所有。 +宿主在发布前校验元数据并解析正文。私有枚举键 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 执行。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 48dc139dc6..287deaff20 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -3,4 +3,4 @@ # 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: b25c84a3467db26776007bb4888391ba69ada3cd +2026-07-06-sandbox.zh.md: 4e3d4951f71a602bc78551c90dda04317d0f8284 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index b25c84a346..4e3d4951f7 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -54,7 +54,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 策略随每次调用而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。 -该 seam 仅约束同世界子进程:后端共享主机的文件系统和内核。容器、microVM 和远程执行器不是此 seam 的后端——它们以环境一致的组替换整个能力实现(`ctx.bash`、`ctx.fs`),因为一个 bash 在容器中运行而 fs 工具写主机的 agent 生活在两个割裂的世界中。 +该 seam 仅约束与宿主机共享文件系统和内核的子进程。容器、microVM 和远程执行器不是此 seam 的后端——它们以环境一致的组替换整个能力实现(`ctx.bash`、`ctx.fs`),因为一个 bash 在容器中运行而 fs 工具写主机的 agent 生活在两个割裂的世界中。 留待需要时再决定:网络限制是作为独立的 `network_mode` 到来,还是在某个 runner 同时强制两者后合并进 `sandbox_mode`;以及 `SandboxPolicy` 是现在就增加额外的可写根授权(launcher 已支持 `--rw <path>`),还是等到升级机制需要时再加。 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 7ffb7e55e5..f199fdb731 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -3,4 +3,4 @@ # 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: ca87abc0ba3c61ae62e321a11d2f026ba5a33d5c +2026-07-07-mcp-client-plugin.zh.md: a60aa537a03dec5add7e91fd78b91fece4e4a259 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index ca87abc0ba..a60aa537a0 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -210,5 +210,5 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha - `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 有有界静默期;卡住的传输层最终在框架层面超时。 +- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 有有界的完全停稳过程;卡住的传输层最终在框架层面超时。 - 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置作为未来工作保持开放。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index cb3d29f1ab..f91e3b5d42 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -3,4 +3,4 @@ # 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: 75d024c49878e26ecbda416d24c02941a1c754db +2026-07-08-self-referential-cordis-toolset.zh.md: 2ec79037045fdb040cccf31699789abdd3a12db2 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 75d024c498..2ec7903704 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -22,7 +22,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 |---|---| | `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_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)是其完整呈现。 @@ -67,7 +67,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 | 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇(cordis 插件)覆盖当前和未来的所有效果 | | 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通的 cordis 语义 | | 可审视性 | 注册的东西无法在插件列表中显示为插件 | 模型挂载的正是 `cordis_inspect` 渲染的 | -| 模型人机工程学 | 对最常见的单一场景有优势(无插件样板) | 通过 mount 描述中的规范示例加边界错误信息教会正确调用来缓解 | +| 模型易用性 | 对最常见的单一场景有优势(无插件样板) | 通过 mount 描述中的规范示例加边界错误信息教会正确调用来缓解 | 因此正确性投入放在能一次性为所有能力带来回报的地方:通过 `cordis_inspect` 呈现的生成 API 目录,以及沙箱边界校验(其错误信息教会正确的调用方式)。结构化注册工具日后仍可作为语法糖添加,由它合成 mount 代码;本设计不排斥这一可能。 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index c824a95b5e..6ae4e17a2b 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-16-pnpm-over-yarn.md: 9dee405f509897e2a173399e466d574c518fa9ab -2026-06-16-pnpm-over-yarn.zh.md: 1f289444bc4cf8dc6b0691be9d4e318b18f5342d +2026-06-16-pnpm-over-yarn.zh.md: 3a5ff5e9b1fb0511a9191b2e3c35b61f00b705cd diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index 1f289444bc..3a5ff5e9b1 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -23,7 +23,7 @@ Status: implemented ## 曾考虑的替代方案 - **保留 Yarn 4**——零变动,但押注于使用率较低的链接器模式和一个绑定单一包管理器的约束引擎。 -- **npm workspaces**——无处不在,但没有约束方案,monorepo 人体工学也较弱。 +- **npm workspaces**——无处不在,但没有约束方案,monorepo 开发体验也较差。 - **pnpm 搭配提升式链接器**——迁移更平滑,但放弃了幻影依赖安全性,而这正是迁移的核心正确性理由。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index 877bf2cdf2..f59c523dce 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-core-data-structures-catalog.md: d7e9d3d9b14fe723e3396c8167fe714b7613e2b5 -2026-06-20-core-data-structures-catalog.zh.md: 48eb661f6bf777a71d4da3be43a30c840b7aa0b2 +2026-06-20-core-data-structures-catalog.zh.md: 8d2f16a46216cba8df0539be0abd2f1ad840eec3 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 48eb661f6b..8d2f16a462 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -31,7 +31,7 @@ Status: implemented 持久性要求很具体:文档展示当前类型声明与原始 JSDoc 的**逐字**内容(让读者看到真实形状和源码契约,而非复述),**并且**以机械方式保证其与源码匹配。仓库已经会编译 ` ```ts ` 围栏块(`doc-typecheck`),但真正接受类型检查的块需要导入噪音,而且只能证明*可赋值性*——字段改名或 JSDoc 变化仍可能通过。因此: -- 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载与源码等价的类环境投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在退出检查比例之外**——它们是单独受检的类别,而不是未经检查的草图。 +- 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ` ```ts type-equiv ` 围栏中。简洁的 ` ```ts public-api ` 围栏承载与源码等价的类环境投影,用于实现体不应进入目录的类。`doc-typecheck` 会识别并跳过这两种围栏(裸声明无法独立编译),并且**将它们排除在 opt-out 比例之外**——它们是单独受检的类别,而不是未经检查的草图。 - 新增的 `scripts/verify-type-equiv.ts` 通过 TypeScript 解析器提取每个块,并断言其声明结构和每条 JSDoc 注释都与所声明的符号匹配,只忽略格式空白和非 JSDoc 注释。普通块保留完整声明。`public-api` 投影保留类的公共字段、构造函数、访问器和方法及其原始 JSDoc,同时移除实现体以及私有或受保护成员。之所以选择它而非编译式 `_Check` 断言,是因为目录所保留的是源码名称与文档一致性,而不是可赋值性。 - 来源信息存放在集中的 `scripts/type-equiv.manifest.json`(`{ doc, symbol, source }` 条目)中,**而非**行文中的指令注释。脚本强制执行 **1:1 对应**:每个 type-equiv 块恰好有一条 manifest 条目,反之亦然;因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。 - 接入 `doc-sync`,因此相关文档变更会在本地运行它,CI 也会与其他文档检查一起运行它。 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml index 3182edd49f..16fb9e2d1e 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generated-cordis-catalog.md: b5957cf06a9316447aae70183de462024bb24be3 -2026-06-20-generated-cordis-catalog.zh.md: 33983d7693e1c67ac210b3ce23d28e7d46571ff9 +2026-06-20-generated-cordis-catalog.zh.md: 35ed06c4a8e13245a37adaa4d5da7a842e97c0c7 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md index 33983d7693..35ed06c4a8 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md @@ -23,7 +23,7 @@ Status: implemented - **`@mode` 标签,交叉校验。** 每个 harness 事件的 JSDoc 携带一个显式的 `@mode emit|waterfall|parallel|serial` 标签;缺少标签时生成器直接报错。当签名形状具有决定性时——尾部参数为 `next: () => …` 在结构上即为 waterfall(瀑布式事件)——生成器断言标签与之一致,矛盾时直接报错。emit/parallel/serial 的区别在结构上不可见(`session/flush` 返回 `Promise<void> | void` 且无 `next`,有序的 `agent/pre-step` 检查点亦然),因此信任标签。编写规则见 [AGENTS.md](../../../../AGENTS.md)。 - **分层范围。** harness 层(8 个 `@deepseek-ai/dsh-*` 服务及其事件)从源码完整渲染。继承层(cordis-core 的 `ctx.on/emit/effect/provide/…` + `internal/*` 事件 + loader/hmr/timer)是插件同样可见的固定 vendor 源码;它从生成器中一张人工维护的表格简洁渲染(名称 + 一行描述 + 源码指针),而非遍历 vendor AST。原因是 cordis-core 的 `Context` 混合了真正的 ctx 成员与非服务字段(`root`、`baseUrl`、`logger`),且 vendor 接口面仅在有意的 vendor 同步时才变化。 - **指向数据结构目录的交叉链接。** 签名中由仓库拥有的每个类型名(`GenerateOptions`、`StreamChunk`、`ToolDefinition`……)都会通过人工维护的映射链接到其主要核心数据结构页面。AST 遍历采用失败关闭策略:每个参数、泛型约束/默认值和返回类型引用都必须已映射、是签名自身的类型参数、是点名的 TypeScript/Cordis 基础类型,或带有点名的例外及其非目录文档归属。违规会连同源码位置汇总报告,并点明相应的归属列表。该映射不会复用 `type-equiv.manifest.json`,因为后者记录 `…Map` 符号,而签名引用派生的联合类型名,并且会在多个页面列出某些符号。 -- **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,并把原始事件或公共方法 JSDoc 直接放在其声明之前。`doc-typecheck` 会识别并跳过这些裸片段,将其排除在退出检查比例之外——与 `type-equiv` 块的处理相同。 +- **专用围栏。** 签名块使用 ` ```ts cordis-catalog ` 信息字符串,并把原始事件或公共方法 JSDoc 直接放在其声明之前。`doc-typecheck` 会识别并跳过这些裸片段,将其排除在 opt-out 比例之外——与 `type-equiv` 块的处理相同。 本决策**取代** [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)中事件分类的那一半:`verify-event-taxonomy` 及其 `docs/architecture.md` 表格退役(architecture.md 的标题保留,正文改为指向目录;服务映射的角色表格作为人工行文保留)。doc-typecheck、verify-md-wrap、verify-md-links 和 verify-type-equiv 不受影响。 diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml index 996cc824f6..65a2b2da28 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-tool-schema-catalog.md: c8cc69df428f6eee0f66ed976865afe2a0702448 -2026-07-02-tool-schema-catalog.zh.md: 1494d982df284b192767af2b22ef53fa87a79d7e +2026-07-02-tool-schema-catalog.zh.md: f08cb5b5312dd07f91037a4382bf5e416cae552d diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md index 1494d982df..f08cb5b531 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md @@ -21,7 +21,7 @@ Cordis 目录是纯 TypeScript AST 遍历,因为每个事件/服务名都是 - `tool-subagent` 的工具名是 `config.toolName ?? 'subagent'`——加载时选定,并非字面量。 - MCP 插件可以通过 `ctx.tools.register()` 直接注册**原始 JSON Schema**,完全不经过 `defineTool`,因此结构化枚举 `defineTool(` 调用点会遗漏。 -唯一忠实的事实来源,是插件加载后注册表实际持有的 schema。启动插件是把[测试策略](../../../../docs/testing.md)中“验证现实,而非自我报告”的准则应用到文档生成器:读取已发布产物,而非重新推导一份。 +唯一准确的真源,是插件加载后注册表实际持有的 schema。启动插件是把[测试策略](../../../../docs/testing.md)中“验证现实,而非自我报告”的准则应用到文档生成器:读取已发布产物,而非重新推导一份。 ### 恢复「不会静默遗漏」的保证 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 368f281520..d49010e985 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-doc-tiers-and-budgets.md: 9993eda6b0c7f1f8e5908dbd85fcfb4e5c6b3d1d -2026-07-04-doc-tiers-and-budgets.zh.md: f0f8910989ad23b98d314fd592d9691cf15c726f +2026-07-04-doc-tiers-and-budgets.zh.md: a40e7c2fe56ef86137bb7bb3ee8c818bdeed7eb9 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index f0f8910989..a40e7c2fe5 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -13,7 +13,7 @@ Status: implemented - **每项事实只归属一处的层级分类。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:它为每种 Markdown 层级分配单一职责(常设指令、系统图、类型目录、决策记录、事件故事、操作指南、各包契约、生成式目录、工作流),禁止在事实归属层级之外重复陈述(应改为链接),并包含编写或评审任何文档时使用的赘余检查清单。 - **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其字数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。 - **上限是只进不退的执行红线。** 上限设定为文档当前字数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600;`packages/README.md` ≤ 600)。推进机制与[翻译配对的 `required` 清单](2026-07-02-bilingual-docs-and-pairing-gate.md)相同。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身即为可评审的动作。 -- **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为事实来源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。 +- **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml index 2356421d05..00db07d233 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-persistence-log-catalog.md: 1529f41b485c1bc8ca029c0a9264574fa7a886a0 -2026-07-04-persistence-log-catalog.zh.md: ff88a6c278033f2861365690faa7c9653e06f211 +2026-07-04-persistence-log-catalog.zh.md: f3f77bb66f8798d953fed16bc79cadadc35c036a diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md index ff88a6c278..f3f77bb66f 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md @@ -18,7 +18,7 @@ Status: implemented - **强制保证 JSDoc 完整性。** 每个成员和渲染出的信封类型都必须带有描述正文,完整的源码 JSDoc 会在目录中保持附着于其声明。`@mode` 标签是硬错误:分派模式属于 Cordis 总线事件,持久化记录没有这种模式。所有违规会汇总为一条错误,列出每个违规项。 - **surface 徽章由派生得出,而非手工列举。** `SurfaceEventType`(产生 LLM(大语言模型)消息且可能携带 `surfaceOp` 的子集)从拥有方包中的 union 声明解析;如果 union 成员命名了一个未声明的事件,则为硬错误(否则陈旧的 union 成员会静默地不标注任何内容)。其余一律渲染为 **log-only**。 -- **专用围栏。** 声明块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 会识别并跳过这些块,将其排除在退出检查比例之外——处理方式与 `ts cordis-catalog` 相同(这些声明引用所属模块中的类型,无法独立编译)。 +- **专用围栏。** 声明块使用 ` ```ts persistence-catalog ` 信息字符串,`doc-typecheck` 会识别并跳过这些块,将其排除在 opt-out 比例之外——处理方式与 `ts cordis-catalog` 相同(这些声明引用所属模块中的类型,无法独立编译)。 - **仓库范围。** 目录枚举本仓库中的包,与兄弟文档的 packages-only 范围一致;下游插件可以合并更多事件类型,它们在设计上不在目录范围内。遍历过程用硬错误保护自身假设:拥有方的顶层 `interface SessionEventMap` 必须是 `@deepseek-ai/dsh-session` 中唯一的导出声明(无关的、局部的或同名重复的接口不能被当作磁盘词汇编入目录);任何声明不得携带 `extends`(继承的键会加入 `keyof SessionEventMap` 却没有对应的目录行);每个成员必须是带有显式 payload 类型的属性签名(方法形式的成员会加入 `keyof` 却在静默遍历中被漏过);跨声明的重复成员也会失败。 本方案取代了手工副本:session.md 的 `hook/*` 表格、精简版 README 的事件表格、hook-protocol README 的 payload 条目列表,以及会话 README 的名称列表现在链接到目录,而不再重述 payload(周围的语义说明文字保留原位)。hook-protocol 合并成员上的两个误加的 `@mode emit` 标签已被移除——新门禁将它们作为类别错误拒绝。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index 15f88684be..7a5f7fdb48 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-public-agent-stop-surface.md: 81a21de30bfbc25688069efbffb21647889b1bdb -2026-06-20-public-agent-stop-surface.zh.md: 664be4ac783005c9f415e737c534ca2326a6a00b +2026-06-20-public-agent-stop-surface.zh.md: 1f5a9ea3c1c3dd6f598c258574a81e52e291f3b7 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index 664be4ac78..1f5a9ea3c1 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-20-public-agent-stop-surface.md) | 中文 -> **实现说明:** 仅移除了 `abort()`。`whenIdle()` 予以保留,因为它是公开的静默信号,能安全处理等待者结算与替换轮次竞态;消费方不应从状态转换中自行重建该行为。 +> **实现说明:** 仅移除了 `abort()`。`whenIdle()` 予以保留,因为它是公开的完全停稳信号,能安全处理等待者结算与替换轮次竞态;消费方不应从状态转换中自行重建该行为。 ## 问题 @@ -18,17 +18,17 @@ Status: implemented `cancel()` 是 `Agent` 上唯一的公共*停止*原语。生命周期拥有者使用 `AgentHandle.dispose()` 停止并注销 agent;非拥有者使用 `cancel()` 放弃当前和已排队工作。实现保留一个私有轮次取消 holder,但它不属于面向插件的 `Agent` 契约。 -`whenIdle()` **保留**为公开的静默观测原语(agent 从 `running` 状态稳定后 resolve,已处于 idle 时立即 resolve,dispose 后等待循环退出)。它不是停止动词;它是非所有者在不 dispose agent 的前提下观测停止*完成*的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 销毁它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 +`whenIdle()` **保留**为公开的完全停稳观测原语(agent 从 `running` 状态稳定后 resolve,已处于 idle 时立即 resolve,dispose 后等待循环退出)。它不是停止动词;它是非所有者在不 dispose agent 的前提下观测停止*完成*的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/ui/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 销毁它们,因此 `packages/ui/acp/src` 本身没有 `whenIdle()` 调用。 公共 `abort()` 已不存在,disposer 仍为异步并等待循环停止。测试通过公共类型化原因和显式 signal seam 验证取消,而不会伸入 holder 内部。 ## 曾考虑的替代方案 -**同时移除 `whenIdle()`**:最初提案的形态,在对照代码验证前提后被推翻(上方的实现说明记录了完整过程):它是承重的静默原语,迫使消费方手动观测 `running`→`idle` 转换正是防御性模式所警告的脆弱路径。 +**同时移除 `whenIdle()`**:最初提案的形态,在对照代码验证前提后被推翻(上方的实现说明记录了完整过程):它是承重的完全停稳原语,迫使消费方手动观测 `running`→`idle` 转换正是防御性模式所警告的脆弱路径。 ## 验证 -`Agent` 不再暴露公开的 `abort()`,而 `cancel()`、`whenIdle()` 和 `steer()` 保留;ACP 取消调用 `cancel()`;拆卸通过 handle disposal 等待静默,`whenIdle()` 在静默时为非所有者观测者 resolve;测试套件覆盖取消和 disposal 作为两条受支持的停止路径。 +`Agent` 不再暴露公开的 `abort()`,而 `cancel()`、`whenIdle()` 和 `steer()` 保留;ACP 取消调用 `cancel()`;拆卸通过 handle disposal 等待完全停稳,`whenIdle()` 在完全停稳时为非所有者观测者 resolve;测试套件覆盖取消和 disposal 作为两条受支持的停止路径。 ## 后果 @@ -36,4 +36,4 @@ Status: implemented ## 相关 -本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;静止观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、会话和 identity。 +本 Agent Note 只移除冗余的停止动词。轮次中途 steering 仍是一条有意保留的消息路径;完全停稳观察仍通过 `whenIdle()` 完成。最终公共表面包括 `send()`、`steer()`、`inject()`、`cancel()`、`whenIdle()`、status、options、会话和 identity。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml index 98bcf89301..9e90a3a343 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-remove-agent-boundary-mirror-events.md: 8c5bb74f2347fe0269cbb9c6504137de761ab919 -2026-06-20-remove-agent-boundary-mirror-events.zh.md: 782d07a01d41753409ab6fc3ef75921673bd5754 +2026-06-20-remove-agent-boundary-mirror-events.zh.md: 1bb166b7f4453b4f0605e2b1b9120bc5a05266b0 diff --git a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md index 782d07a01d..1bb166b7f4 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md @@ -15,7 +15,7 @@ Status: implemented ## 问题 -循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费方在同一持久事实的两个事实来源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择会话日志,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费方;它已经从 `session/event` 渲染工具调用和结果。 +循环在 `SessionEvent` 中记录规范 transcript(文本记录),同时还发出一组并行的实时 `agent/*` 边界镜像事件:`agent/turn-start`、`agent/turn-end`、`agent/step-start` 和 `agent/step-end`。这些镜像迫使消费方在同一持久事实的两个真源之间做选择。ACP(Agent Client Protocol)已经为面向编辑器的 transcript 选择会话日志,因为它是唯一持久、可重放的记录;消费实时镜像需要把它的时序与日志中已经存储的边界进行调和。stdio UI 是唯一仍从镜像事件渲染轮次边界的生产消费方;它已经从 `session/event` 渲染工具调用和结果。 这种重复并非零成本。每次生命周期变更都需要同时更新会话事件、镜像事件、文档、不变式、测试和快照预期。重复的边界事件还使失败排序变得微妙:一个轮次可能在实时 `agent/turn-end` 监听器运行之前就已被持久化关闭,因此边界之后的监听器失败在日志中已没有合法位置可以插入,只能带外上报。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml index 29467c4b98..291dd7d009 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-unify-agent-and-session-id.md: c55152f4f13fe0acb530503e84f465799007cff7 -2026-06-20-unify-agent-and-session-id.zh.md: ac6f7e09b20b2b6e17ca0e9f6ebb088abaf5a996 +2026-06-20-unify-agent-and-session-id.zh.md: 1fa2fe1fd64478bfe17c590e45abd0cf8281cbe4 diff --git a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md index ac6f7e09b2..1fa2fe1fd6 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-unify-agent-and-session-id.zh.md @@ -10,7 +10,7 @@ Status: implemented ACP(Agent Client Protocol)对两种 identity 使用相同值。Stdio 和钩子也在会话事件流上工作,并且直接需要对应的实时 agent;没有生产路径会把一个实时 agent 对象重新附着到多个会话,或通过多个 agent id 驱动一个会话。 -[agent 范围运行时](../architecture/2026-07-12-agent-scope-runtime-design.md)使用同一个 `AgentCreationTransaction` 执行创建和恢复,agent/会话条目共享相同的最终条目冲突规则。第二个 identity 并不代表单独的存活性、回滚或静止状态;它只会围绕同一事务增加 API 与转换状态。 +[agent 范围运行时](../architecture/2026-07-12-agent-scope-runtime-design.md)使用同一个 `AgentCreationTransaction` 执行创建和恢复,agent/会话条目共享相同的最终条目冲突规则。第二个 identity 并不代表单独的存活性、回滚或完全停稳;它只会围绕同一事务增加 API 与转换状态。 会话 identity 同样只有一个归属,即 `Session.header.id`;`Session.id` 是派生访问器,而非需要重复验证的独立状态。 @@ -29,7 +29,7 @@ agent 的注册表 id 等于其会话 id。`CreateAgentOptions` 接受一个 `se ## 验证 - Agent 创建/恢复和 subagent 创建只携带一个 identity,`Session` 也只在一个位置存储它。 -- 创建事务继续覆盖最终条目冲突、精确条目分离、回滚和静止状态,无需 identity 特有的生命周期状态。 +- 创建事务继续覆盖最终条目冲突、精确条目分离、回滚和完全停稳,无需 identity 特有的生命周期状态。 - ACP、stdio、钩子、bash 归属、持久化和 lineage 直接使用共享 `SessionId`。ACP subagent 后端在父命名空间中铸造其生命周期 id,因为子服务器返回的会话 id 仅在服务器本地有效;ACP bridge 根据正向会话 map 验证精确的 `Agent` 归属;JSON-RPC 只转发生命周期事件中由服务快照保存的 `local` 标记为 true 的事件,从带范围的事件 carrier 取得委托父项,并且不保留子 identity 或 lineage cache。 - 配置驱动的恢复还是创建策略是显式的,并在持久化重启场景下得到覆盖。 - 生产监听器搜索确认保留 `agent/created`/`agent/disposed` 及其发布语义。 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 1f3571ea76..b6b4adcc16 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-02-remove-stream-chunk-mirror.md: 5dd816940a4b2c2b63980e01f1bd4e0aac53a3e2 -2026-07-02-remove-stream-chunk-mirror.zh.md: cb197205e0954d8197c2c4861d7f113e0461678d +2026-07-02-remove-stream-chunk-mirror.zh.md: ca65a3c2ad46d14835416c14d0087ef542de03bb diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index cb197205e0..ca65a3c2ad 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -19,7 +19,7 @@ ctx.emit('agent/stream-chunk', agent, turn, step, chunk) // ← the mirror 实时发射相比会话事件唯一多出的东西是实时的 `Agent` 句柄,而唯一的消费方直接丢弃了它(其处理函数签名为 `(_agent, _turn, _step, chunk)`)。 -这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复相同:消费方面对同一持久事实的两个事实来源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把分片流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将分片流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 +这与[移除边界镜像](2026-06-20-remove-agent-boundary-mirror-events.md)为轮次/步骤边界消除的重复相同:消费方面对同一持久事实的两个真源,每次变更都必须同时触及两者。该 Agent Note(agent 决策记录)没有把分片流一并纳入,而是推迟处理(“`assistant/chunk` 持久化仍承载关键约束,所以以后可以将分片流作为镜像评估,但那是一项独立决策”)。本 Agent Note 就是那项独立决策。 推迟所依赖的前提已经明确:分片持久化是权威的,且将保留。停止持久化分片、仅保留瞬态实时流事件的提案已被[否决](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)——高保真回放、部分失败的流以及快照回放都依赖持久化的 `assistant/chunk` 序列。因此 `session/event` 上的 `assistant/chunk` 是持久的、承重的 token 流,而 `agent/stream-chunk` 是它的纯冗余镜像。 diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml index 745fa459c6..e36caa3f12 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-16-typed-event-schemas.md: 97a7d0c3787eb5556696e25a6c8b1bb75642aba9 -2026-06-16-typed-event-schemas.zh.md: bde8425839d99f7dbf7ed38eb606d1cdfec5c27f +2026-06-16-typed-event-schemas.zh.md: c19f67c6ff058d42293ff2b6346630fe91c54dec diff --git a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md index bde8425839..c19f67c6ff 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md +++ b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md @@ -54,7 +54,7 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 用运行时注册表替换 merge-extensible map,生产者向其贡献 schema,持久化/消费路径据此校验。 - **优点**:持久化边界和插件 seam 处获得真正的运行时校验;单一真源;可支撑通用工具(自动生成文档、模糊测试、协议格式检查)。 -- **缺点**:上述全部影响范围;**Zod 目前不是直接依赖**(仅作为 `@earendil-works/pi-ai` 的传递依赖),仓库选定的 schema 库是 **schemastery**——广泛引入 Zod 本身就是一个依赖决策;声明合并的人体工学(一行插件扩展、完整推断)被运行时注册 + 手动类型接线取代;`assertNever` 穷举保证弱化(运行时变体在静态层面不可穷举)。 +- **缺点**:上述全部影响范围;**Zod 目前不是直接依赖**(仅作为 `@earendil-works/pi-ai` 的传递依赖),仓库选定的 schema 库是 **schemastery**——广泛引入 Zod 本身就是一个依赖决策;声明合并的易用性(一行插件扩展、完整推断)被运行时注册 + 手动类型接线取代;`assertNever` 穷举保证弱化(运行时变体在静态层面不可穷举)。 ## 提案 @@ -68,7 +68,7 @@ harness 将其核心词汇——内容块、消息来源、结束原因、轮次 ## 风险 - 推迟意味着事件 `data` 在持久化边界处仍无结构校验:格式错误但仍为合法 JSON 的数据被延迟捕获,由消费方的 `switch` 兜底——这是现状的代价,有意接受。 -- 如果方案 C 最终被采纳,人体工学的损失是真实的:一行声明合并变为运行时注册加手动类型接线,`assertNever` 的静态穷举保证弱化。 +- 如果方案 C 最终被采纳,易用性的损失是真实的:一行声明合并变为运行时注册加手动类型接线,`assertNever` 的静态穷举保证弱化。 ## 待解问题 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 744078f75d..024adc68d9 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-04-prune-dead-core-spine-surface.md: 432aaa848540fe7d1db3039399345a02afed5bd3 -2026-07-04-prune-dead-core-spine-surface.zh.md: 9e2203fab4fc0b2b0bec6ff956a3b572c0c9062a +2026-07-04-prune-dead-core-spine-surface.zh.md: 2c7cc4c0dc234afc107bdfc715cf717e5ad8c55c diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 9e2203fab4..2c7cc4c0dc 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -43,11 +43,11 @@ Status: proposed ## 提案 -以一次有界的、协调的公开接口清理,移除或降级上述每一行。同步更新包 README、JSDoc、生成的 API/事件 catalog、type-equiv 记录、必要的 exports map 以及测试,使测试通过所属的公开 seam 验证行为,而非保留仅为测试而存在的入口。不折叠任何能力 seam、LLM(大语言模型)适配器、持久化后端或生命周期静默契约。 +以一次有界的、协调的公开接口清理,移除或降级上述每一行。同步更新包 README、JSDoc、生成的 API/事件 catalog、type-equiv 记录、必要的 exports map 以及测试,使测试通过所属的公开 seam 验证行为,而非保留仅为测试而存在的入口。不折叠任何能力 seam、LLM(大语言模型)适配器、持久化后端或生命周期完全停稳契约。 ## 曾考虑的替代方案 -**保留测试便利函数和自包含的结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更符合人体工学,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;当前它们让每处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小契约,其所有权和失败语义明确。 +**保留测试便利函数和自包含的结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更易用,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;当前它们让每处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小契约,其所有权和失败语义明确。 **保留所有 catalog 成员以供模型编写的 mount 使用。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务接口,而非无限期保留重复字段或不一致的参数对;上述每一项 catalog 收缩都移除了在同一次执行、同一个 agent(智能体)或同一结果中其他位置已可获得的事实,并在同一变更中更新 API 参考。 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 749ac25ddb..1b4e9c4b54 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-collapse-workflow-to-foreground-core.md: 629e2140523c3ae7caf533de99821206d05f1b8e -2026-07-12-collapse-workflow-to-foreground-core.zh.md: f71ea19f563ff64a1f638c644d35f51148e493cb +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 3ae5e026a0b123a6b695b339010bf14a99515912 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index f71ea19f56..3ae5e026a0 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -31,7 +31,7 @@ live handle 在观测者消失后仍重复事件时代的数据。`WorkflowRun.i - 工作流公开 seam 仅包含有生产消费方的执行、取消、结果与 dispose 契约。 - 不再保留任何工作流事件、phase/log 协议消息、run-id 生成器、仅供进度使用的元数据、host 配对账本或 fatal 模式分支。 - run handle 不再有 id/meta 回显,取消在同步 `start()` 返回后只有一条持有者拥有的通道。 -- parallel/pipeline 行为、上限、取消静默、worker 隔离、结构化输出与面向模型的工作流场景保持测试覆盖。 +- parallel/pipeline 行为、上限、取消后的完全停稳、worker 隔离、结构化输出与面向模型的工作流场景保持测试覆盖。 - 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建与 hygiene 全部通过。 ## 风险 From bc329a962bd426a01afcef97f0207fc8d7672630 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:16:18 +0800 Subject: [PATCH 085/113] docs(i18n): codify review terminology --- docs/i18n/terminology.md | 6 ++++++ .../translation-prompt-v4/request-response.expected.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index aac0f1f461..cc21071cfa 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -104,10 +104,12 @@ | durability | 持久性 | | | | | feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 | | enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 | +| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 | | event | 事件 | | | | | event log | 事件日志 | | | | | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| Executive summary | 摘要 | | | 事故复盘标题用语 | | executor | 执行器 | | | | | expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | | extension | 扩展 | | | | @@ -133,6 +135,7 @@ | model provider | 模型提供方 | | | | | module | 模块 | | | | | npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 | +| opt-out ratio | opt-out 比例 | | 退出检查比例 | | | orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 | | orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 | | package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 | @@ -146,12 +149,14 @@ | provider | 提供方 | | | | | provider-neutral | 提供方无关 | | | | | quality gate | 质量门禁 | | | | +| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 | | reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 | | reasoning_content | 思考内容 | | | | | registry | 注册表 | | | | | replay | 回放 | | | | | resume | 恢复 | | | | | runtime | 运行时 | | | | +| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | | | sandbox | 沙箱 | | | | | service | 服务 | | | | | serving surface | 对外服务接口 | | | | @@ -168,6 +173,7 @@ | stream | 流 | | | | | streaming | 流式输出 | | | | | structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) | +| Summary | 概述 | | | 事故复盘标题用语 | | system prompt | 系统提示词 | | | | | taxonomy | 分类体系 | | | | | token usage | token 用量 | | | | diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 9fb243459e..82aebc4d6b 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 领域层级为 Session > Round > Turn(轮次) > Step(步骤);Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", From be29b012f1209af37d4adabb69ce0f0da72d1661 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 15:39:04 +0800 Subject: [PATCH 086/113] fix(jsonrpc): update agent followup test doubles --- packages/ui/jsonrpc/tests/server.spec.ts | 31 ++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 43dc690c72..91d3503263 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' @@ -170,16 +170,16 @@ describe('HarnessSdkServer', () => { const mainWhenIdle = vi.fn<() => Promise<void>>() .mockReturnValueOnce(firstMainIdle) .mockResolvedValue(undefined) - const mainSend = vi.fn() - const mainAgent = { - send: mainSend, + const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup')) + const mainAgent = ({ + followup: mainFollowup, whenIdle: mainWhenIdle, - } as unknown as Agent - const otherSend = vi.fn() - const otherAgent = { - send: otherSend, + } satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent + const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup')) + const otherAgent = ({ + followup: otherFollowup, whenIdle: vi.fn(() => Promise.resolve()), - } as unknown as Agent + } satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } const create = vi.fn(async (options: { sessionId: SessionId }) => @@ -196,7 +196,7 @@ describe('HarnessSdkServer', () => { }) const first = prompt('main', 'first') - await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() }) + await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() }) await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main') await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true }) @@ -208,8 +208,8 @@ describe('HarnessSdkServer', () => { await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed') await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true }) - expect(mainSend).toHaveBeenCalledTimes(4) - expect(otherSend).toHaveBeenCalledOnce() + expect(mainFollowup).toHaveBeenCalledTimes(4) + expect(otherFollowup).toHaveBeenCalledOnce() await server.shutdown() expect(mainHandle.dispose).toHaveBeenCalledOnce() expect(otherHandle.dispose).toHaveBeenCalledOnce() @@ -225,9 +225,9 @@ describe('HarnessSdkServer', () => { shutdown(): Promise<Record<string, never>> } const session = ctx.sessions.create(SessionId('message-outcome')) - const agent = { + const agent = ({ session, - send(content: { type: 'text'; text: string }[]) { + followup(content: { type: 'text'; text: string }[]) { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } }, @@ -246,9 +246,10 @@ describe('HarnessSdkServer', () => { source: { kind: 'plugin', plugin: 'late-metadata' }, }, { surfaceOp: 'append' }) session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + return AgentMessageId('message-outcome') }, whenIdle: () => Promise.resolve(), - } as unknown as Agent + } satisfies Pick<Agent, 'session' | 'followup' | 'whenIdle'>) as unknown as Agent server.sessions.set('message-outcome', { handle: { agent, dispose: () => Promise.resolve() }, lastTurnEnd: undefined, From 90a789c03a7eef1f6ea0a2e48677ce3827a008b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:41:14 +0800 Subject: [PATCH 087/113] docs(i18n): preserve cancellation quiescence --- ...2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml | 2 +- .../2026-07-07-claude-code-and-codex-subagent-backends.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index e53e9f69c2..8c7cbf5cc2 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: bd7133a4b8e761ebeb61cea1d26631eab40f13d2 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 3d5ccf33b105f9beeca760f82ec849d2ded5ac01 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index bd7133a4b8..3d5ccf33b1 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -77,7 +77,7 @@ Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema ## 验收标准 -在两个引擎和密钥均已配置的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终答案,父会话日志中仅有 `tool/call` + `tool/result`。无密钥套件在无凭证环境下以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程环境、dispose 后无残留临时配置目录),并断言 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内静默,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。 +在两个引擎和密钥均已配置的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终答案,父会话日志中仅有 `tool/call` + `tool/result`。无密钥套件在无凭证环境下以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程环境、dispose 后无残留临时配置目录),并断言 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内完全停稳,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。 ## 风险 From 445bb89f03bf6e03bb7b6bb05349e3c9fd5fa3d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:51:06 +0800 Subject: [PATCH 088/113] docs(i18n): complete core batch review contract --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 6 ++++++ .../2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 6 ++++++ docs/cookbook/extension-cookbook.i18n.yaml | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/defensive-patterns.i18n.yaml | 2 +- docs/defensive-patterns.zh.md | 2 +- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 8 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index a1d901ad15..3bfc8414e7 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: 3be1d5d8fd9dba20cfca34c79cb01d89fad8097a -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: a8aa8812934e755fe0175c8f3f20d194e4d24b4a +2026-07-02-bilingual-docs-and-pairing-gate.md: 4bc02878a0ea3f998e411ecc2b064c1626eacf3c +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 90a0c2f07f68b0fb4e26cd1c4b537a30a829b10f diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 3be1d5d8fd..4bc02878a0 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,8 +13,14 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. +- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. +- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration. - **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent. +## Verification + +The verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible. + ## Alternatives considered - **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged. diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index a8aa881293..90a0c2f07f 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,8 +13,14 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 +- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 +- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。 - **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。 +## 验证 + +验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。 + ## 曾考虑的替代方案 - **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 5d5dc81f2a..2f0ed06b5b 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write extension-cookbook.md: 056be4298ed2bec2b78ed777d58f1f8a60a34b78 -extension-cookbook.zh.md: 41cdd4a7d14f32494d1dd5ae4a63c098d5640bdc +extension-cookbook.zh.md: 4574bcb0fa8b27d35e0fe612da028c471b4f9533 diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 41cdd4a7d1..4574bcb0fa 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -60,7 +60,7 @@ export function apply(ctx: Context) { ## 客户端驱动插件(外部协议桥接) -*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。 +*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent,确保 dispose(资源释放)流程完全停稳。 `packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。 diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index b39cad3e24..acdfaf96df 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write defensive-patterns.md: 349b916df6f7544300dacd578acf42668d9436ac -defensive-patterns.zh.md: 19565f54595195a52d1b49ff487294945171ae2d +defensive-patterns.zh.md: a6bbe317b220e31cce0e1bfc7c3b7c87d2d5cf46 diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index 19565f5459..a6bbe317b2 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -16,7 +16,7 @@ `agent.send()` 不会在返回前翻转状态;后台任务的完成与轮次边界存在竞争;`reader.close()` 在 EOF 和 dispose(资源释放)两种情况下都会触发。切勿基于一个刚刚请求的状态来控制流程——应以实际触发的事件/promise(`agent/status`、`task.done`)驱动生命周期,并观察状态转换(先看到 `running` 再看到 `idle`),而不是把状态当作单次发送的结果:多个排队发送会在同一个 `running` 区间内连续运行多个轮次,而取消或资源释放可能丢弃尚未启动的项。这条守则是双向的:如果等待的转换永远不会发生(EOF 时没有提交过任何工作 → 永远不会进入 `running`),等待就会挂起——请显式处理「无需等待」的分支。 -## Dispose 必须达到静止,而不仅仅是请求停止 +## Dispose 必须达到完全停稳,而不仅仅是请求停止 一个清理流程如果发出 kill/abort 后就返回、而不等待工作实际停止,就会留下孤儿进程。请让清理逻辑异步化并 await 子进程退出(kill → await `done`),并在 kill 之前关闭监听器/通知注册表,使迟到的完成事件保持静默。测试应证明 dispose 确实等待了(`await fiber.dispose()` 之后 pid 已不存在),而不仅仅是进程最终会死。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 82aebc4d6b..b0ec0a98cd 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -40,11 +40,11 @@ }, { "role": "user", - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog.\n- The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.\n" + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows.\n- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog.\n- The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", - "content": "# Agent Note:通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog。\n- 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" + "content": "# Agent Note:通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。\n- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog。\n- 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。\n" }, { "role": "user", From 31fb3a3f44ea75ced79a9191708d4b6ffd016e91 Mon Sep 17 00:00:00 2001 From: _Kerman <kermanx@qq.com> Date: Fri, 24 Jul 2026 16:08:54 +0800 Subject: [PATCH 089/113] docs(agent): propose context injection semantics --- ...xt-injection-from-turn-execution.i18n.yaml | 6 ++ ...e-context-injection-from-turn-execution.md | 75 +++++++++++++++++++ ...ontext-injection-from-turn-execution.zh.md | 75 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md create mode 100644 .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml new file mode 100644 index 0000000000..233cc3901c --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-separate-context-injection-from-turn-execution.md: 652c3d410ab625d91a828f854bce302adcb0c9e0 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: 1064e7a869ab9ea46c0145eb010119894a03aacf diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md new file mode 100644 index 0000000000..652c3d410a --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -0,0 +1,75 @@ +# Agent Note: Separate context injection from turn execution + +Status: proposed + +English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md) + +## Problem + +The agent API currently represents supplementary model-facing input in three overlapping ways: callers attach `HookContext[]` through `SendOptions.contexts`, interception and tool hooks return `additionalContexts`, and plugins call `agent.inject()`. These paths eventually write context into the same model history, but they carry different placement, metadata, admission, queue, and turn-lifecycle rules. + +Atomic attachment to an inbox message forces the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combines context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers can recover what the user actually wrote. The result makes outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer. + +Idle `inject()` exposes a second mismatch. Injection does not request model execution, yet the current implementation opens and closes a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes means “run the agent loop” and sometimes means “persist context without running it.” + +`HookContext` also names its producer rather than its role. The value may come from a native plugin, a hook bridge, prompt admission, or tool post-processing. Its stable meaning is simply additional model-facing context with provenance. + +## Proposal + +Make `inject()` the only caller-facing operation for adding supplementary model-facing input, and define a turn exclusively as one execution of the model loop. + +Remove `SendOptions.contexts`. A caller that owns context delivers it with `inject()` and independently submits the direct message with `send()` or `steer()`. Rename `HookContext` to `AdditionalContext`; retain only `content` and `source`, and remove placement and model-hidden metadata from this shared shape. + +Prompt and tool extension points may still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt enters the outbox together with its returned additional contexts; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the same outbox after the corresponding tool results. + +Every additional context becomes an independent `user/message` whose `source` records provenance. Remove `context/message`, prompt-prefix placement, the stable request delimiter, and the prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`, not by recovering a hidden direct-prompt field from combined model content. + +## Injection lifecycle + +When a turn is open, `inject()` stages the context in the loop outbox. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: a context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. Taking the outbox as a whole makes steering and injected context accepted for one boundary visible to the same following request. + +When no turn is open, `inject()` appends its `user/message` immediately and starts a session flush. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model. The synchronous API still returns before the asynchronous flush settles; `whenIdle()` and agent disposal include outstanding idle-injection flushes in their quiescence boundary. + +A failed idle flush has no legitimate turn or step coordinates. It is reported through logging or a persistence-owned error surface, not by inventing an `agent/error` payload for a nonexistent turn. The in-memory event remains accepted and a later flush may retry persistence. + +The session invariant therefore permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction code must treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. + +## Extension and caller semantics + +`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. + +Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. A caller that invokes `inject(context)` and then `send(prompt)` has already committed context independently; if prompt admission later blocks the prompt, the injected context remains in history. Callers requiring all-or-nothing domain behavior must perform their own preparation before either operation or expose a domain-specific admission seam. + +Cross-session references follow the ordinary composition: the host prepares the snapshot, injects it with session-reference provenance, then sends or steers the readable direct prompt. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../../implemented/feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. + +This proposal preserves the caller-owned framing decision from [unwrapped injected content](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../../implemented/simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. + +## Alternatives considered + +**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. + +**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. + +**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. + +**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. + +**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. + +## Acceptance criteria + +- `SendOptions` and steering inbox records contain no attached contexts; `agent/queued` reports only the retained message and steering facts. +- `AdditionalContext` replaces `HookContext` across prompt interception, tool execution, hook bridges, guards, and context producers, with only `content` and `source`. +- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. +- Idle `inject()` appends and flushes one sourced `user/message` without a turn or model call; `whenIdle()` and disposal await the flush. +- Active-turn injection and hook-produced contexts drain at safe boundaries after complete tool-result batches and before the request that consumes them. +- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; independently injected caller context remains. +- Unit, persistence/resume, invariant, ACP/TUI replay, and keyless assembled-application snapshots cover the new event order and durability semantics. + +## Risks + +- Allowing one surface event outside turns weakens a simple invariant and may expose hidden assumptions in persistence scanning, crash repair, forking, compaction, and session queries. +- Consecutive user-role messages replace one baked prompt message; provider adapters and cache behavior must accept and preserve that ordering. +- `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller accepts the independent-commit contract. +- A synchronous injection API cannot return flush failure. Logging alone is less structured than `agent/error`, while adding a new persistence event solely for this case may create another unnecessary seam. +- Removing attachment, placement, metadata, envelopes, and a durable event type is a broad pre-release migration that must update every producer and consumer atomically. diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md new file mode 100644 index 0000000000..1064e7a869 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -0,0 +1,75 @@ +# Agent Note: 将上下文注入与轮次执行分离 + +Status: proposed + +[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文 + +## 问题 + +agent API 目前用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都会把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。 + +将上下文原子附加到收件箱消息后,agent loop(智能体循环)必须让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又会把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方需要依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都必须处理本应由生产方负责的区分。 + +空闲状态下的 `inject()` 还暴露了另一处语义错位。注入并不请求模型执行,但当前实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。 + +`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义只是带来源信息的额外模型上下文。 + +## 提案 + +将 `inject()` 设为调用方添加补充模型输入的唯一操作,并把轮次严格定义为一次模型循环执行。 + +移除 `SendOptions.contexts`。拥有上下文的调用方通过 `inject()` 交付上下文,再独立使用 `send()` 或 `steer()` 提交直接消息。将 `HookContext` 重命名为 `AdditionalContext`;这个共享结构只保留 `content` 和 `source`,移除放置方式与模型不可见元数据。 + +提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。提示词获准后,它与返回的额外上下文一同进入 outbox;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入同一个 outbox。 + +每项额外上下文都成为独立的 `user/message`,并由 `source` 记录来源。移除 `context/message`、prompt-prefix 放置方式、稳定请求分隔符和提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文,无需从合并后的模型内容中恢复隐藏的直接提示词字段。 + +## 注入生命周期 + +轮次打开时,`inject()` 将上下文暂存在 loop outbox 中。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接纳的上下文,只能出现在该批次所有有序结果之后。系统整体取走 outbox,确保同一边界接纳的 steering 和注入上下文对后续同一次请求可见。 + +没有打开的轮次时,`inject()` 会立即追加对应的 `user/message` 并启动会话刷新。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型。同步 API 仍会在异步刷新完成前返回;`whenIdle()` 和 agent dispose(资源释放)会把尚未结束的空闲注入刷新纳入静止边界。 + +空闲刷新失败时不存在合法的轮次或步骤坐标。系统通过日志或持久化所属的错误接口报告该失败,而不是为不存在的轮次伪造 `agent/error` 载荷。内存中的事件仍已接纳,后续刷新可以重试持久化。 + +因此,会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork、压缩和查询逻辑必须把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 + +## 扩展点与调用方语义 + +`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 + +调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,上下文已独立提交;后续提示词准入即使阻止该提示词,注入上下文仍保留在历史中。需要领域级全有或全无语义的调用方,必须在执行任一操作前自行完成准备,或提供领域专用的准入 seam。 + +跨会话引用使用普通组合方式:宿主先准备快照,以会话引用来源调用 `inject()`,再发送或 steer 可读的直接提示词。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本提案取代[跨会话引用决策](../../implemented/feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 + +本提案保留[移除注入内容封套](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../../implemented/simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 + +## 曾考虑的替代方案 + +**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 + +**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 + +**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 + +**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 + +**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 + +## 验收标准 + +- `SendOptions` 与 steering 收件箱记录不再包含附加上下文;`agent/queued` 只报告保留的消息和 steering 事实。 +- `AdditionalContext` 在提示词拦截、工具执行、hook bridge、guard 和上下文生产方中取代 `HookContext`,且只包含 `content` 与 `source`。 +- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 +- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加并刷新一条带来源的 `user/message`;`whenIdle()` 和 dispose 会等待该刷新。 +- 活跃轮次注入和钩子产生的上下文会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 +- 被提示词准入阻止的消息不会打开轮次,也不会追加提示词或钩子产生的额外上下文;调用方此前独立注入的上下文仍保留。 +- 单元测试、持久化与 resume 测试、不变量测试、ACP/TUI 回放测试,以及无需密钥的组装应用快照覆盖新的事件顺序和持久性语义。 + +## 风险 + +- 允许一个表层事件位于轮次之外,会削弱一条简单不变量,并可能暴露持久化扫描、崩溃恢复、fork、压缩和会话查询中的隐含假设。 +- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器和缓存行为必须接受并保留这一顺序。 +- 如果调用方不能接受独立提交契约,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文。 +- 同步注入 API 无法返回刷新失败。只记录日志的结构化程度低于 `agent/error`,但仅为此场景增加新的持久化事件也可能产生另一个不必要的 seam。 +- 移除附件、放置方式、元数据、封套和一种持久事件类型,是一次影响面较广的预发布迁移,必须原子更新所有生产方和消费方。 From 6f5321cb37d7f456381533c15c3ec1ba2046e8ef Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 16:09:58 +0800 Subject: [PATCH 090/113] feat: workspace select menu --- ...ew-session-clears-to-empty-state.i18n.yaml | 4 +- ...07-24-new-session-clears-to-empty-state.md | 2 +- ...24-new-session-clears-to-empty-state.zh.md | 2 +- .../runtime/src/client/sessions/service.ts | 23 +- .../runtime/tests/sessions-service.spec.ts | 21 ++ .../ui-conversation/src/client/apply.ts | 4 + .../src/client/contract/slots.ts | 5 + .../src/client/skeleton/EmptyState.module.css | 87 ++++-- .../src/client/skeleton/EmptyState.tsx | 256 +++++++++++++----- .../src/client/skeleton/InputBar.module.css | 58 ++-- .../src/client/skeleton/InputBar.tsx | 10 +- .../tests/apply-inject.spec.tsx | 9 +- .../tests/skeleton-branches.spec.tsx | 38 ++- .../ui-conversation/tests/skeleton.spec.tsx | 76 +++++- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/package.json | 2 +- .../ui-primitives/src/Button.module.css | 14 + packages/client/ui-primitives/src/Button.tsx | 2 +- .../client/ui-primitives/src/Menu.module.css | 81 +++++- packages/client/ui-primitives/src/Menu.tsx | 109 ++++++-- .../client/ui-primitives/src/Modal.module.css | 79 ++++++ packages/client/ui-primitives/src/Modal.tsx | 62 +++++ packages/client/ui-primitives/src/index.ts | 5 +- .../client/ui-primitives/tests/atoms.spec.tsx | 87 +++++- packages/host/runtime/src/api-proxy.ts | 14 +- .../host/runtime/tests/host-runtime.spec.ts | 25 +- 26 files changed, 890 insertions(+), 187 deletions(-) create mode 100644 packages/client/ui-primitives/src/Modal.module.css create mode 100644 packages/client/ui-primitives/src/Modal.tsx diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml index 5f41ac4b70..4b7354a322 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db -2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895 +2026-07-24-new-session-clears-to-empty-state.md: 1605f44a05d0f59b61fe95cb5b03a0f9f5c3d4ab +2026-07-24-new-session-clears-to-empty-state.zh.md: 1f78d99babc33d30ee1300bfa6bf048a78e7132e diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md index d730f3e0b6..1605f44a05 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -20,4 +20,4 @@ Sidebar "New Session" created and opened a blank session immediately, so the cen ## Consequences -New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `<select>` state only — host plan, access, and model seams remain unwired. +New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero (Input_Bottom 75:8208) as fish + title, a Menu-backed workspace chip above the card, then shared `InputBar` (`variant="hero"`, max-width 800, r20 card matching the composer — not a taller r24 hero), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776` asset ratio) so it scales with it. The chip uses the soft interactive hover fill + 12px radius from 75:8208 and opens MenuDropdown (figma 122:9481; `--dsw-specific-menu` + `--dsw-shadow-lv3`): basename rows with folder icons and a trailing check, then a separator and "New Workspace" whose submenu (figma 419:16920) offers "Use a existing folder" and "Create new". Use a existing folder opens the path Dialog (figma 451:18655 copy — "Enter an existing folder path" / Open Folder) over a full-viewport mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`) and sets the chip cwd. Create new opens the same Dialog chrome to name a folder under `host.describe().cwd`; success runs `sessions.createWorkspace` → host `session.create` (mkdir recursive) → `sessions.open`, so a default session lands in the new workspace. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `<select>` state only — host plan, access, and model seams remain unwired. diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md index 602a2b774b..1f78d99bab 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md @@ -20,4 +20,4 @@ Status: implemented ## Consequences -New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区:鱼标 + 标题、卡片上方的 Menu 工作区 chip(「New Workspace」/ 路径 basename / 自由输入路径),再接共用的 `InputBar`(`variant="hero"`);选择器与卡片背后居中铺一层柔光椭圆(figma 313:14109),宽度按卡片锁定为 `1051/776`,随卡片缩放。`InputBar` 绘制底栏 chrome(添加 / Plan / Read-only / 模型),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。 +New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区(Input_Bottom 75:8208):鱼标 + 标题、卡片上方由 Menu 驱动的工作区 chip,再接共用的 `InputBar`(`variant="hero"`,max-width 800,与 composer 一致的 r20 卡片——而非更高的 r24 英雄区),选择器与卡片背后居中铺一层柔光椭圆(figma 313:14109),宽度按卡片锁定为 `1051/776` asset 比例,随卡片缩放。Chip 采用 75:8208 的柔和交互 hover 填充与 12px 圆角,并打开 MenuDropdown(figma 122:9481;`--dsw-specific-menu` + `--dsw-shadow-lv3`):带文件夹图标与尾随勾选的 basename 行,分隔线后是 "New Workspace",其子菜单(figma 419:16920)提供 "Use a existing folder" 与 "Create new"。Use a existing folder 打开路径 Dialog(figma 451:18655 copy — "Enter an existing folder path" / Open Folder),置于全视口遮罩(`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`)之上,并设置 chip 的 cwd。Create new 打开同一套 Dialog chrome,在 `host.describe().cwd` 下命名文件夹;成功则走 `sessions.createWorkspace` → host `session.create`(mkdir recursive)→ `sessions.open`,在新 workspace 中默认落下一会话。`InputBar` 绘制底栏 chrome(attach / Plan / Read-only / model),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 324c574474..d8a6f05762 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -117,7 +117,7 @@ export class SessionsService { * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. */ - constructor(private readonly rootCtx: Context, api: IApiClient) { + constructor(private readonly rootCtx: Context, private readonly api: IApiClient) { this.manager = new SessionManager(api) this.selection = createSnapshotStore<{ sessionId?: SessionId }>( {}, @@ -171,6 +171,27 @@ export class SessionsService { return result.value.sessionId } + /** + * Create a workspace folder under the host process cwd and a session in it. + * Name is a single path segment (no separators); the host mkdir runs inside + * session.create. Caller opens the returned id when it wants the session staged. + * @param name - workspace folder basename. + * @returns the new session id. + */ + async createWorkspace(name: string): Promise<SessionId> { + const trimmed = name.trim() + if (trimmed === '') throw new Error('sessions.createWorkspace: name is required') + if (/[/\\]/.test(trimmed)) { + throw new Error('sessions.createWorkspace: name must not contain path separators') + } + const { result } = await this.api.host.describe({}) + if (!result.ok) { + throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`) + } + const hostCwd = result.value.cwd.replace(/[/\\]+$/, '') + return this.create({ cwd: `${hostCwd}/${trimmed}` }) + } + /** * Resolve a session-scoped context view (use-and-discard). * @param id - session id. diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 33cfce43bb..8c850426bd 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -297,6 +297,27 @@ describe('create', () => { }) }) +describe('createWorkspace', () => { + it('joins host.describe cwd with the name and creates there', async () => { + const b = bench() + b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 })) + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') })) + await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws') + expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }]) + }) + + it('rejects empty names and path separators; surfaces describe failures', async () => { + const b = bench() + await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/) + await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/) + b.api.onDescribe = () => Promise.resolve({ + rpcId: 'e' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } }, + } as never) + await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/) + }) +}) + describe('coverage tails (branch duals)', () => { it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => { const b = bench() diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8c4a6dc6a1..372eb36c80 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -160,6 +160,10 @@ export function apply(ctx: Context): void { if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') return conversation.startSession(opts) }, + createWorkspaceSession: async (name) => { + const id = await sessions.createWorkspace(name) + sessions.open(id) + }, }), }, EmptyState) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index baa26683ec..ffbc13ff59 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & export interface EmptyStateInjected { /** The create → navigate → first-send chain, in one service call. */ startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> + /** + * Create a workspace folder under the host cwd, mint a session there, and + * open it (Create-new modal success path). + */ + createWorkspaceSession(name: string): Promise<void> } /** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index 53e618b122..bc5d9d62d0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -1,6 +1,6 @@ -/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the - shared InputBar card. The input itself is InputBar — only stack geometry - lives here. */ +/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip + above the shared InputBar card. The input itself is InputBar — only stack + geometry and the chip live here. */ .root { display: flex; @@ -11,23 +11,26 @@ padding: 24px; } -/* Cap matches InputBar card width (776). Glow may paint past the sides. */ +/* Cap matches InputBar card width (800). Glow may paint past the sides. */ .stack { display: flex; flex-direction: column; align-items: stretch; - gap: 40px; + /* figma 75:8208: 12 between title block / workspace / card. */ + gap: 12px; width: 100%; - max-width: 776px; + max-width: 800px; overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block + keeps 36px below the headline before the flex gap. */ .headline { display: flex; align-items: center; justify-content: center; gap: 10px; + padding-bottom: 36px; font-size: 26px; line-height: 32px; font-weight: 600; @@ -68,38 +71,43 @@ z-index: 1; } -.workspaceRow { +/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its + right-hand submenu) paints above the InputBar card. */ +.body > .workspaceRow { + z-index: 10; display: flex; align-items: center; min-width: 0; - /* Align with InputBar's left chrome (card pad 10 + attach). */ - padding-left: 10px; + /* figma 75:8208 workspace row: px 8 above the card. */ + padding-left: 8px; } -/* Folder + "New Workspace" + chevron (figma workspace trigger). */ +/* Folder + label + chevron — transparent at rest; fill only on hover / open. */ .workspace { display: inline-flex; align-items: center; - gap: 6px; + gap: 4px; max-width: 100%; - height: 28px; - padding: 0 4px 0 0; + min-height: 28px; + padding: 0 8px; border: none; - border-radius: 8px; + border-radius: 12px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 14px; + font-size: 13px; line-height: 20px; + font-weight: 500; cursor: pointer; } -.workspace:hover { +.workspace:hover, +.workspace[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } .folder { flex: none; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-primary); } .workspaceLabel { @@ -113,19 +121,44 @@ color: var(--dsw-alias-label-caption); } -.customInput { - width: min(320px, 100%); - height: 28px; - padding: 0 10px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 8px; +/* Workspace menu width tracks the longest basename in the Figma frame. */ +.workspaceMenu :global([role='menu']) { + min-width: 240px; +} + +/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */ +.modalInput { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; outline: none; - background: var(--dsw-alias-bg-base); + background: transparent; font-size: 14px; - line-height: 20px; + line-height: 24px; color: var(--dsw-alias-label-primary); } -.customInput:focus { +.modalInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.modalInput:focus { border-color: var(--dsw-alias-state-business-primary); } + +.modalInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.modalAction { + min-width: 72px; +} + +.modalError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index edcf0cbad2..b112dfa432 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -1,16 +1,21 @@ // EmptyState (figma NEW SESSION screen): centered hero — fish + title, -// workspace picker row, then the SAME InputBar the resident composer uses -// (empty→content is a position move, never a swap). Project picker: cwd set -// derived in-component from useSessions plus a free-form new-directory path; -// submit runs startSession (create → open → send). +// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu +// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident +// composer uses (empty→content is a position move, never a swap). Project +// options derive in-component from useSessions; Create new runs +// createWorkspaceSession (host mkdir + session.create + open). import { useId, useMemo, useState } from 'react' import { + Button, FishLogo, IconChevronDownOutline14, + IconFolderClose16, IconFolderOpen16, + IconPlusOutline16, Menu, - type MenuItem, + Modal, + type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { EmptyStateSlotProps } from '../contract/slots.ts' @@ -18,10 +23,15 @@ import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' -/** Menu id for the free-form directory entry (not a filesystem path). */ -const NEW_DIR = '::new-directory' -/** Menu id for the host default project directory (empty cwd on create). */ -const DEFAULT_DIR = '::default' +/** Menu id for "New Workspace" (opens submenu; not a cwd). */ +const NEW_WORKSPACE = '::new-workspace' +/** Submenu: path modal (figma 451:18655 copy). */ +const USE_EXISTING = '::use-existing' +/** Submenu: create-workspace modal → mkdir + default session. */ +const CREATE_NEW = '::create-new' + +/** Which full-page dialog is open (null = none). */ +type ModalKind = 'path' | 'create' | null /** Full props composed by reference from the contract (runtime share & injected share; no store). */ export type EmptyStateProps = EmptyStateSlotProps @@ -36,22 +46,26 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } -/** Basename for the workspace chip; empty → the design's "New Workspace" label. */ +/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */ function workspaceLabel(cwd: string): string { if (cwd === '') return 'New Workspace' const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() return base !== undefined && base !== '' ? base : cwd } -export function EmptyState({ useSessions, startSession }: EmptyStateProps) { +export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') const [cwd, setCwd] = useState('') - const [custom, setCustom] = useState(false) const [menuOpen, setMenuOpen] = useState(false) + const [modalKind, setModalKind] = useState<ModalKind>(null) + const [pathDraft, setPathDraft] = useState('') + const [workspaceName, setWorkspaceName] = useState('New WorkSpace') + const [creating, setCreating] = useState(false) + const [modalError, setModalError] = useState<string | null>(null) const [sending, setSending] = useState(false) const [error, setError] = useState<InputBarError | null>(null) // Stable filter id so multiple EmptyState mounts do not collide in the DOM. @@ -74,59 +88,64 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } - const items: MenuItem[] = [ - { id: DEFAULT_DIR, label: 'Default directory' }, - ...cwds.map(c => ({ id: c, label: c })), - { id: NEW_DIR, label: 'New directory…' }, + const items: MenuEntry[] = [ + ...cwds.map(c => ({ + id: c, + label: workspaceLabel(c), + icon: <IconFolderClose16 size={16} />, + })), + ...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []), + { + id: NEW_WORKSPACE, + label: 'New Workspace', + icon: <IconPlusOutline16 size={16} />, + submenu: [ + { id: USE_EXISTING, label: 'Use a existing folder' }, + { id: CREATE_NEW, label: 'Create new' }, + ], + }, ] - const selectedId = custom ? NEW_DIR : cwd === '' ? DEFAULT_DIR : cwd - const workspace = custom - ? ( - <input - className={css.customInput} - value={cwd} - autoFocus - aria-label="项目目录" - placeholder="Directory path, e.g. /home/me/proj" - onChange={(e) => { setCwd(e.target.value) }} - /> - ) - : ( - <Menu - open={menuOpen} - onClose={() => { setMenuOpen(false) }} - selectedId={selectedId} - items={items} - onSelect={(id) => { - if (id === NEW_DIR) { - setCustom(true) - setCwd('') - } else if (id === DEFAULT_DIR) { - setCustom(false) - setCwd('') - } else { - setCustom(false) - setCwd(id) - } - setMenuOpen(false) - }} - anchor={( - <button - type="button" - className={css.workspace} - aria-label="项目目录" - aria-haspopup="menu" - aria-expanded={menuOpen} - onClick={() => { setMenuOpen(!menuOpen) }} - > - <IconFolderOpen16 className={css.folder} size={16} /> - <span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span> - <IconChevronDownOutline14 className={css.chevron} size={14} /> - </button> - )} - /> - ) + const closeModal = (): void => { + if (creating) return + setModalKind(null) + setModalError(null) + } + + const openPathModal = (): void => { + setPathDraft(cwd) + setModalError(null) + setModalKind('path') + } + + const openCreateModal = (): void => { + setWorkspaceName('New WorkSpace') + setModalError(null) + setModalKind('create') + } + + const confirmPath = (): void => { + const next = pathDraft.trim() + if (next === '') return + setCwd(next) + setModalKind(null) + } + + const confirmCreate = (): void => { + if (creating) return + setCreating(true) + setModalError(null) + createWorkspaceSession(workspaceName) + .catch((reason: unknown) => { + setModalError(reason instanceof Error ? reason.message : String(reason)) + setCreating(false) + }) + // Success swaps this slot out for the new session body — no local cleanup. + } + + const modalBusy = creating + const isPath = modalKind === 'path' + const isCreate = modalKind === 'create' return ( <div className={css.root}> @@ -138,7 +157,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { </div> <div className={css.body}> {/* figma 313:14109: soft ellipse behind workspace + InputBar; width - tracks the card (1051/776) so blur scales in userSpace with it. */} + tracks the card (glow asset 1051 vs design card 776) so blur + scales in userSpace with it. */} <svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true"> <defs> <filter @@ -159,7 +179,44 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { <ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" /> </g> </svg> - <div className={css.workspaceRow}>{workspace}</div> + <div className={css.workspaceRow}> + <Menu + open={menuOpen} + onClose={() => { setMenuOpen(false) }} + {...(cwd !== '' ? { selectedId: cwd } : {})} + items={items} + side="top" + className={css.workspaceMenu!} + onSelect={(id) => { + if (id === USE_EXISTING) { + setMenuOpen(false) + openPathModal() + return + } + if (id === CREATE_NEW) { + setMenuOpen(false) + openCreateModal() + return + } + setCwd(id) + setMenuOpen(false) + }} + anchor={( + <button + type="button" + className={css.workspace} + aria-label="项目目录" + aria-haspopup="menu" + aria-expanded={menuOpen} + onClick={() => { setMenuOpen(!menuOpen) }} + > + <IconFolderOpen16 className={css.folder} size={16} /> + <span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span> + <IconChevronDownOutline14 className={css.chevron} size={12} /> + </button> + )} + /> + </div> <InputBar draft={draft} running={false} @@ -174,6 +231,75 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { /> </div> </div> + <Modal + open={isPath} + onClose={closeModal} + title="Enter an existing folder path" + footer={( + <> + <Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button> + <Button + variant="primary" + className={css.modalAction!} + disabled={pathDraft.trim() === ''} + onClick={confirmPath} + > + Open Folder + </Button> + </> + )} + > + <input + className={css.modalInput} + value={pathDraft} + aria-label="Folder path" + autoFocus + placeholder="ex. User/Documents/Harness/Space" + onChange={(e) => { setPathDraft(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmPath() + } + }} + /> + </Modal> + <Modal + open={isCreate} + onClose={closeModal} + title="Create new workspace" + footer={( + <> + <Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}> + Cancel + </Button> + <Button + variant="primary" + className={css.modalAction!} + disabled={modalBusy || workspaceName.trim() === ''} + onClick={confirmCreate} + > + Create + </Button> + </> + )} + > + <input + className={css.modalInput} + value={workspaceName} + aria-label="Workspace name" + autoFocus + disabled={modalBusy} + onChange={(e) => { setWorkspaceName(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmCreate() + } + }} + /> + {modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>} + </Modal> </div> ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 04e4f661c5..7161a31931 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -1,7 +1,7 @@ -/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the +/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action row below, one primary circle button bottom-right. Input width rides the - column (776 is a cap, not a fixed size — layout rule: the box shrinks with + column (800 is a cap, not a fixed size — layout rule: the box shrinks with the center column keeping its padding). Hero variant = the same card centered in the empty state; the transition between the two is a position move of one component. */ @@ -10,8 +10,8 @@ display: flex; flex-direction: column; align-items: center; - /* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is - owned by the chat scroller. Top 8 hosts the error strip's breathing room. */ + /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by + the chat scroller. Top 8 hosts the error strip's breathing room. */ padding: 8px 32px 12px; } @@ -21,7 +21,7 @@ .error { width: 100%; - max-width: 776px; + max-width: 800px; margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -34,10 +34,12 @@ .card { display: flex; flex-direction: column; - /* figma Input 34:11458: 12px between the text area and the button row. */ + /* figma Input 75:8208: 12px between the text area and the button row; 10px + top pad on the card before .InputText. */ gap: 12px; width: 100%; - max-width: 776px; + max-width: 800px; + padding-top: 10px; /* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says the input border is one notch weaker than buttons) — exactly the l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */ @@ -49,11 +51,6 @@ line-height: 24px; } -/* New-session state rounds up (figma: r24 and a taller box). */ -.hero .card { - border-radius: 24px; -} - .accessory { display: flex; align-items: center; @@ -85,7 +82,8 @@ .input, .mirror { - padding: 12px 16px 0; + /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */ + padding: 4px 12px 0 16px; font-size: inherit; line-height: inherit; white-space: pre-wrap; @@ -108,17 +106,12 @@ .mirror { visibility: hidden; pointer-events: none; - /* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */ - min-height: 60px; + /* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */ + min-height: 52px; max-height: 336px; overflow: hidden; } -.hero .mirror { - /* New-session box is taller at rest (figma 118px input area). */ - min-height: 84px; -} - /* Toolbar: attach + Plan + Read-only on the left; model + send on the right (figma Input_Bottom chrome). */ .row { @@ -131,16 +124,25 @@ } .tools, +.modes, .trailing { display: flex; align-items: center; - gap: 4px; min-width: 0; } +/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */ +.tools { + gap: 16px; +} + +.modes { + gap: 4px; +} + .trailing { flex: none; - gap: 8px; + gap: 12px; } /* Attach circle (figma + control): 28px, selector fill, primary glyph. */ @@ -166,22 +168,24 @@ cursor: default; } -/* Plan / Read-only / model — native <select>, chip-like closed chrome. */ +/* Plan / Read-only / model — native <select>, chip-like closed chrome + (figma ToggleButton: 13/20 medium secondary, 12px chevron). */ .select { max-width: 220px; height: 28px; - padding: 0 22px 0 6px; + padding: 0 20px 0 8px; border: none; border-radius: 8px; outline: none; background-color: transparent; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3.5 5.25L7 8.75L10.5 5.25' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 4px center; - background-size: 14px 14px; + background-size: 12px 12px; color: var(--dsw-alias-label-secondary); - font-size: 14px; + font-size: 13px; line-height: 20px; + font-weight: 500; white-space: nowrap; cursor: pointer; appearance: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f6454071b3..04d1dd867d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -175,8 +175,10 @@ export function InputBar({ > <IconPlusOutline16 size={14} /> </button> - {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} - {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} + <div className={css.modes}> + {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} + </div> </div> <div className={css.trailing}> {renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} @@ -190,11 +192,11 @@ export function InputBar({ onClick={onPrimary} > {running ? ( - <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden> + <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" /> </svg> ) : ( - <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden> + <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> <path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" /> </svg> )} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3043c71ed5..edd9f7d54d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -78,6 +78,7 @@ async function bench() { cell: () => undefined, scopeOf, create: vi.fn(() => Promise.resolve(ROOT)), + createWorkspace: vi.fn(() => Promise.resolve(ROOT)), open: vi.fn(), } ctx.provide('sessions', sessionsFake) @@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => { expect(details).toBe(conv) }) - it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => { + it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => { const b = await bench() const entry = b.entryOf('conversation.empty') expect(entry.store).toBeUndefined() const injected = (entry.inject as unknown as () => EmptyStateInjected)() - expect(Object.keys(injected)).toEqual(['startSession']) + expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession']) await injected.startSession({ text: 'go', mode: 'queue' }) expect(b.sessionsFake.create).toHaveBeenCalled() expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue') + b.sessionsFake.open.mockClear() + await injected.createWorkspaceSession('Fresh') + expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh') + expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) }) it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => { diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b93b9c0b72..d1bd50437f 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -238,10 +238,16 @@ describe('DetailsPanel branches', () => { }) describe('EmptyState branches', () => { + const noopCreate = () => Promise.resolve() + it('keeps the draft and surfaces a local error strip when startSession rejects', async () => { const startSession = vi.fn(() => Promise.reject(new Error('create down'))) const view = render( - <EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />, + <EmptyState + useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} + startSession={startSession} + createWorkspaceSession={noopCreate} + />, ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'first task' } }) @@ -253,7 +259,11 @@ describe('EmptyState branches', () => { it('non-Error rejection reasons stringify into the error strip', async () => { const startSession = vi.fn(() => Promise.reject('plain-string')) const view = render( - <EmptyState useSessions={listHook([])} startSession={startSession} />, + <EmptyState + useSessions={listHook([])} + startSession={startSession} + createWorkspaceSession={noopCreate} + />, ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'go' } }) @@ -270,15 +280,17 @@ describe('EmptyState branches', () => { { id: 'b', title: 'b' }, // no cwd: filtered from the option set ])} startSession={startSession} + createWorkspaceSession={noopCreate} />, ) fireEvent.click(view.getByRole('button', { name: '项目目录' })) expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['Default directory', '/proj', 'New directory…']) - fireEvent.click(view.getByRole('menuitem', { name: '/proj' })) + .toEqual(['proj', 'New Workspace']) + fireEvent.click(view.getByRole('menuitem', { name: 'proj' })) expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') fireEvent.click(view.getByRole('button', { name: '项目目录' })) - fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' })) + fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) const custom = view.container.querySelector('input')! fireEvent.change(custom, { target: { value: '/typed/dir' } }) const textarea = view.container.querySelector('textarea')! @@ -286,4 +298,20 @@ describe('EmptyState branches', () => { fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' })) }) + + it('Create modal surfaces inject failures inline', async () => { + const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked'))) + const view = render( + <EmptyState + useSessions={listHook([])} + startSession={() => Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(view.getByRole('menuitem', { name: 'Create new' })) + fireEvent.click(view.getByRole('button', { name: 'Create' })) + await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked')) + }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2636bfd4b..a598803a25 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -86,6 +86,8 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId? const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</> describe('EmptyState', () => { + const noopCreate = () => Promise.resolve() + it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => { const { useSessions } = fakeSessions([ { id: 'a', title: 'a', cwd: '/w/app' }, @@ -94,14 +96,20 @@ describe('EmptyState', () => { ]) let reject!: (e: Error) => void const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej })) - render(<EmptyState useSessions={useSessions} startSession={startSession} />) + render( + <EmptyState + useSessions={useSessions} + startSession={startSession} + createWorkspaceSession={noopCreate} + />, + ) const trigger = screen.getByRole('button', { name: '项目目录' }) fireEvent.click(trigger) const menu = screen.getByRole('menu') expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…']) - fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' })) + .toEqual(['app', 'lib', 'New Workspace']) + fireEvent.click(screen.getByRole('menuitem', { name: 'app' })) const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') fireEvent.change(box, { target: { value: '造一个轮子' } }) fireEvent.keyDown(box, { key: 'Enter' }) @@ -113,14 +121,64 @@ describe('EmptyState', () => { expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') }) - it('new-directory option swaps the chip for a free-form input', () => { + it('Use a existing folder opens the path modal and Open Folder sets the chip', () => { const { useSessions } = fakeSessions([]) - render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />) + render( + <EmptyState + useSessions={useSessions} + startSession={() => Promise.resolve()} + createWorkspaceSession={noopCreate} + />, + ) fireEvent.click(screen.getByRole('button', { name: '项目目录' })) - fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' })) - const custom = screen.getByPlaceholderText(/Directory path/) - fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) - expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') + const newWs = screen.getByRole('menuitem', { name: 'New Workspace' }) + fireEvent.mouseEnter(newWs.parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' })) + expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy() + const path = screen.getByLabelText('Folder path') as HTMLInputElement + fireEvent.change(path, { target: { value: '/tmp/fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'Open Folder' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh') + }) + + it('Create new opens the modal and createWorkspaceSession succeeds', async () => { + const { useSessions } = fakeSessions([]) + const createWorkspaceSession = vi.fn(() => Promise.resolve()) + render( + <EmptyState + useSessions={useSessions} + startSession={() => Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) + expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy() + const name = screen.getByLabelText('Workspace name') as HTMLInputElement + expect(name.value).toBe('New WorkSpace') + fireEvent.change(name, { target: { value: 'My Proj' } }) + fireEvent.keyDown(name, { key: 'Enter' }) + await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj')) + }) + + it('Create modal Cancel dismisses without calling createWorkspaceSession', () => { + const { useSessions } = fakeSessions([]) + const createWorkspaceSession = vi.fn(() => Promise.resolve()) + render( + <EmptyState + useSessions={useSessions} + startSession={() => Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(createWorkspaceSession).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index da6382be4b..5b158c453a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-primitives -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. ## Markdown rendering diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index fda27fdd4d..44c35eb517 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", - "description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Input, markdown family (zero cordis)", + "description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 1cb3b18194..3f415b5d15 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -56,6 +56,20 @@ background: var(--dsw-alias-interactive-bg-active); } +/* Dialog Cancel (figma 451:18655): bordered capsule on transparent fill. */ +.outline { + border: 1px solid var(--dsw-alias-border-l2); + background: transparent; +} + +.outline:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.outline:disabled { + border-color: var(--dsw-alias-border-l1); +} + .toolbar { background: var(--dsw-alias-button-tool-bar-fill); } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 028c1fc266..642372868a 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import css from './Button.module.css' /** Visual variant, each backed by its --dsw-alias-button-* token family. */ -export type ButtonVariant = 'primary' | 'ghost' | 'toolbar' +export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' /** * Render a button. diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 3e3bf85299..3cbcb62d29 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -3,21 +3,32 @@ display: inline-flex; } -/* Dropdown card (figma MenuDropdown 122:10096): white card, r12, no border, - * layered drop shadows via the shadow token, 4px inset padding. */ +/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface, + * r12, inverted hairline border, shadow-lv3, 4px inset padding. */ +.list, +.submenu { + padding: 4px; + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); +} + .list { position: absolute; top: calc(100% + 4px); left: 0; z-index: 100; min-width: 130px; - padding: 4px; - display: flex; - flex-direction: column; - gap: 0; - border-radius: 12px; - background: var(--dsw-alias-bg-layer-1); - box-shadow: var(--dsw-shadow-lv2); +} + +/* Open above the anchor (empty-state workspace chip: figma 122:9481). */ +.sideTop { + top: auto; + bottom: calc(100% + 4px); } .alignEnd { @@ -25,12 +36,18 @@ right: 0; } -/* Menu cell (figma .Menu_cell 27:5169): r10, pad 10/8, 14/22 primary text, +.itemWrap { + position: relative; +} + +/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary, * gap 8 between leading icon / label / trailing check. */ .item { display: flex; align-items: center; gap: 8px; + width: 100%; + min-height: 40px; padding: 8px 10px; border: none; border-radius: 10px; @@ -51,9 +68,22 @@ cursor: not-allowed; } +.itemIcon { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + color: var(--dsw-alias-label-tertiary); +} + .itemLabel { flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .check { @@ -66,3 +96,34 @@ .selected { background: transparent; } + +/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ +.separator { + height: 1px; + margin: 4px 2px; + background: var(--dsw-alias-border-l1); +} + +/* Nested card to the right of the parent row (figma 419:16920). + * Bottom-aligned with the parent menu card (grows upward): itemWrap sits in + * .list's 4px pad, so bottom: -4px matches the list's outer bottom edge. + * Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain + * `100% + 6px` collapses to ~2px between outer card edges. + * ::before bridges the full gap so the pointer can cross without mouseLeave. */ +.submenu { + position: absolute; + top: auto; + bottom: -4px; + left: calc(100% + 10px); + z-index: 101; + min-width: 160px; +} + +.submenu::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -10px; + width: 10px; +} diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ad45acc221..0b07c26357 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,46 +1,69 @@ // Menu: minimal controlled dropdown (group-by pickers, project selectors). // Pure CSS positioning relative to the anchor wrapper — no portal, no popper. // The owner controls `open`; outside-click closing uses one document listener -// active only while open. +// active only while open. Submenus open on hover/focus inside the same root. -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' import css from './Menu.module.css' -/** One selectable menu row. */ +/** Selectable row (optionally with a nested submenu). */ export interface MenuItem { id: string label: ReactNode disabled?: boolean + /** Leading icon (figma .Menu_cell gap 8). */ + icon?: ReactNode + /** Nested card opened to the right on hover/focus. */ + submenu?: readonly MenuItem[] +} + +/** Hairline between item groups (not selectable). */ +export interface MenuSeparator { + type: 'separator' + id: string +} + +/** One primary-menu entry: a row or a separator. */ +export type MenuEntry = MenuItem | MenuSeparator + +function isSeparator(entry: MenuEntry): entry is MenuSeparator { + return 'type' in entry && entry.type === 'separator' } /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). - * @param props.items - selectable rows. + * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. - * @param props.onSelect - row click callback (not called for disabled rows). + * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). + * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: { open: boolean anchor: ReactNode - items: readonly MenuItem[] + items: readonly MenuEntry[] selectedId?: string onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' + side?: 'bottom' | 'top' className?: string }) { const rootRef = useRef<HTMLSpanElement>(null) + const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null) useEffect(() => { - if (!open) return + if (!open) { + setOpenSubmenuId(null) + return + } const onPointerDown = (e: PointerEvent) => { if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose() } @@ -59,21 +82,61 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align <span ref={rootRef} className={clsx(css.root, className)}> {anchor} {open && ( - <div className={clsx(css.list, align === 'end' && css.alignEnd)} role="menu"> - {items.map(item => ( - <button - key={item.id} - type="button" - role="menuitem" - className={clsx(css.item, item.id === selectedId && css.selected)} - disabled={item.disabled} - onClick={() => onSelect(item.id)} - > - <span className={css.itemLabel}>{item.label}</span> - {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */} - {item.id === selectedId && <IconCheckOutline16 className={css.check} />} - </button> - ))} + <div className={clsx(css.list, side === 'top' && css.sideTop, align === 'end' && css.alignEnd)} role="menu"> + {items.map(entry => { + if (isSeparator(entry)) { + return <div key={entry.id} className={css.separator} role="separator" /> + } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( + <div + key={entry.id} + className={css.itemWrap} + onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + <button + type="button" + role="menuitem" + className={clsx(css.item, entry.id === selectedId && css.selected)} + disabled={entry.disabled} + aria-haspopup={hasSub ? 'menu' : undefined} + aria-expanded={hasSub ? subOpen : undefined} + onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }} + onClick={() => { + if (hasSub) { + setOpenSubmenuId(entry.id) + return + } + onSelect(entry.id) + }} + > + {entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>} + <span className={css.itemLabel}>{entry.label}</span> + {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */} + {entry.id === selectedId && <IconCheckOutline16 className={css.check} />} + </button> + {subOpen && entry.submenu !== undefined && ( + <div className={css.submenu} role="menu"> + {entry.submenu.map(sub => ( + <button + key={sub.id} + type="button" + role="menuitem" + className={css.item} + disabled={sub.disabled} + onClick={() => { onSelect(sub.id) }} + > + {sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>} + <span className={css.itemLabel}>{sub.label}</span> + </button> + ))} + </div> + )} + </div> + ) + })} </div> )} </span> diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css new file mode 100644 index 0000000000..49026f7a5f --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -0,0 +1,79 @@ +/* Full-viewport layer (figma Mask + Dialog 451:18655): mask + centered card. */ +.root { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +/* User/spec mask: rgba(0,0,0,0.24) + blur(2px) via --dsw-alias-bg-mask-1 / + --dsw-mask-blur (light); dark theme raises mask opacity. */ +.mask { + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); +} + +/* Dialog card: r24, shadow-lv3, layer-2 fill, inverted border, pb 24. */ +.dialog { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 20px; + width: min(380px, 100%); + padding: 0 0 24px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 24px; + background: var(--dsw-alias-bg-layer-2); + box-shadow: var(--dsw-shadow-lv3); +} + +.content { + display: flex; + flex-direction: column; + width: 100%; +} + +/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */ +.header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 22px 14px 12px 24px; +} + +.title { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.description { + margin: 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); +} + +.body { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0 24px; +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 0 24px; +} diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx new file mode 100644 index 0000000000..cdbe1060bf --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -0,0 +1,62 @@ +// Modal: controlled full-viewport dialog (create-workspace and similar). +// Fixed overlay in the React tree (no react-dom portal) so ui-primitives +// stays free of a react-dom dependency; mask tokens match figma 451:18655. + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import css from './Modal.module.css' + +/** + * Render a centered modal over a blurred page mask. + * @param props.open - whether the dialog is showing. + * @param props.onClose - Escape or mask click. + * @param props.title - dialog heading. + * @param props.description - optional supporting sentence under the title. + * @param props.children - body (inputs, etc.). + * @param props.footer - action row (Cancel / Create). + * @returns null when closed; otherwise the overlay tree. + */ +export function Modal({ open, onClose, title, description, children, footer, className }: { + open: boolean + onClose: () => void + title: string + description?: string + children?: ReactNode + footer?: ReactNode + className?: string +}) { + useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => { document.removeEventListener('keydown', onKeyDown) } + }, [open, onClose]) + + if (!open) return null + + return ( + <div className={css.root} role="presentation"> + <div className={css.mask} aria-hidden="true" onClick={onClose} /> + <div + className={clsx(css.dialog, className)} + role="dialog" + aria-modal="true" + aria-label={title} + > + <div className={css.content}> + <div className={css.header}> + <h2 className={css.title}>{title}</h2> + {description !== undefined && description !== '' && ( + <p className={css.description}>{description}</p> + )} + </div> + {children !== undefined && <div className={css.body}>{children}</div>} + </div> + {footer !== undefined && <div className={css.footer}>{footer}</div>} + </div> + </div> + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index e5e2e4e88f..0d6cde4ed0 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -1,5 +1,5 @@ /** - * Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Input, + * Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input, * markdown family, ConnectionBanner. Everything consumes props plus --dsw-* * token vars only. Contract: api-contracts v3 section 8. */ @@ -11,7 +11,8 @@ export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' export { Input } from './Input.tsx' export { Menu } from './Menu.tsx' -export type { MenuItem } from './Menu.tsx' +export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx' +export { Modal } from './Modal.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index f259cb334a..a4b286ced7 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Button, ConnectionBanner, Input, Menu, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) @@ -21,6 +21,11 @@ describe('Button', () => { fireEvent.click(screen.getByRole('button')) expect(onClick).not.toHaveBeenCalled() }) + + it('outline variant renders a bordered cancel-style button', () => { + render(<Button variant="outline">Cancel</Button>) + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined() + }) }) describe('Pill', () => { @@ -91,11 +96,12 @@ describe('Menu', () => { expect(onClose).not.toHaveBeenCalled() }) - it('selected item shows the trailing check; align=end and className apply', () => { + it('selected item shows the trailing check; align=end, side=top, and className apply', () => { const { container } = render( <Menu open align="end" + side="top" className="x" anchor={<span>trigger</span>} items={items} @@ -104,12 +110,89 @@ describe('Menu', () => { onClose={() => {}} />) expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true) + const menu = screen.getByRole('menu') + expect(menu.className).toMatch(/sideTop|alignEnd/) const selected = screen.getByRole('menuitem', { name: 'Alpha' }) expect(selected.querySelector('svg')).not.toBeNull() const other = screen.getByRole('menuitem', { name: 'Beta' }) expect(other.querySelector('svg')).toBeNull() fireEvent.keyDown(document, { key: 'a' }) }) + + it('renders a leading icon and a separator between groups', () => { + render( + <Menu + open + anchor={<span>trigger</span>} + items={[ + { id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> }, + { type: 'separator', id: 's1' }, + { id: 'c', label: 'Create' }, + ]} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.getByTestId('ic')).toBeDefined() + expect(screen.getByRole('separator')).toBeDefined() + }) + + it('opens a submenu on hover and selects a nested item', () => { + const onSelect = vi.fn() + render( + <Menu + open + anchor={<span>trigger</span>} + items={[ + { id: 'plain', label: 'Plain' }, + { + id: 'new', + label: 'New Workspace', + submenu: [ + { id: 'ok', label: 'Create ok', icon: <svg data-testid="sub-ic" /> }, + ], + }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const plain = screen.getByRole('menuitem', { name: 'Plain' }) + fireEvent.mouseEnter(plain.parentElement as HTMLElement) + fireEvent.focus(plain) + const parent = screen.getByRole('menuitem', { name: 'New Workspace' }) + const wrap = parent.parentElement as HTMLElement + fireEvent.click(parent) + expect(onSelect).not.toHaveBeenCalled() + fireEvent.focus(parent) + fireEvent.mouseEnter(wrap) + expect(screen.getByTestId('sub-ic')).toBeDefined() + fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' })) + expect(onSelect).toHaveBeenCalledWith('ok') + fireEvent.mouseLeave(wrap) + expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull() + }) +}) + +describe('Modal', () => { + it('is absent while closed; Escape and mask click call onClose', () => { + const onClose = vi.fn() + const { rerender } = render( + <Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>) + expect(screen.queryByRole('dialog')).toBeNull() + rerender( + <Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}> + <input aria-label="name" /> + </Modal>) + expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined() + expect(screen.getByText('Name it.')).toBeDefined() + fireEvent.keyDown(document, { key: 'a' }) + expect(onClose).not.toHaveBeenCalled() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + // Mask is the presentation sibling behind the dialog. + const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement + fireEvent.click(mask) + expect(onClose).toHaveBeenCalledTimes(2) + }) }) describe('ConnectionBanner', () => { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..3792d17da3 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -4,7 +4,7 @@ */ import { randomUUID } from 'node:crypto' -import { stat } from 'node:fs/promises' +import { mkdir, stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' @@ -407,8 +407,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const sessionId = `session-${randomUUID()}` as SessionId // A session's cwd is its project path. When the creator does not choose // one, the default project is the host-level default (the host process - // working directory unless boot overrides it). + // working directory unless boot overrides it). Ensure the directory + // exists so Create-workspace and typed paths land on a real folder. const cwd = request.payload.cwd ?? defaults.cwd + try { + await mkdir(cwd, { recursive: true }) + } catch (error: unknown) { + return err(request, { + code: 'internal', + message: `failed to ensure project directory "${cwd}": ${String(error)}`, + details: {}, + }) + } const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } }) return ok(request, { sessionId: handle.agent.id }) }, diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..4058fdfe2e 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -231,6 +231,29 @@ describe('sessions.create / list', () => { expect(first?.running).toBe(false) expect(first?.parentSessionId).toBeUndefined() }) + + it('ensures a missing project directory before minting the session', async () => { + const { api } = await boot() + const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-')) + const cwd = join(root, 'nested', 'workspace') + expect(existsSync(cwd)).toBe(false) + const { sessionId } = expectOk(await api.sessions.create(request({ cwd }))) + expect(existsSync(cwd)).toBe(true) + const { items } = expectOk(await api.sessions.list(request({}))) + expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd) + }) + + it('fails loud when the project directory cannot be created', async () => { + const { api } = await boot() + const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-')) + const blocker = join(root, 'file-not-dir') + writeFileSync(blocker, 'x') + const response = await api.sessions.create(request({ cwd: join(blocker, 'child') })) + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('expected mkdir failure') + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toMatch(/failed to ensure project directory/) + }) }) describe('sessions.prompt / cancel', () => { From 2db7342fc6a8b4ee78f2f4fba0f6b3d9f1b532d1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:13:20 +0800 Subject: [PATCH 091/113] docs(i18n): address final Agent Note review --- .../2026-06-20-generic-long-running-tool-runtime.i18n.yaml | 2 +- .../2026-06-20-generic-long-running-tool-runtime.zh.md | 2 +- .../notes/implemented/feature/2026-06-15-code-mode.i18n.yaml | 2 +- .agents/notes/implemented/feature/2026-06-15-code-mode.zh.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index df140d462c..ec8a2059a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-20-generic-long-running-tool-runtime.md: 25668a6a699576e435670b9580385073f2f036fe -2026-06-20-generic-long-running-tool-runtime.zh.md: d6db17b441d965dfaed1847f0dcfa4fd6fc25bb4 +2026-06-20-generic-long-running-tool-runtime.zh.md: 3c214bb4309ea331ae2d4ba73c01df1ded8f40ef diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index d6db17b441..3c214bb430 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -69,7 +69,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 ## 面向模型的控制接口 -`dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 ACP 卡片: +`dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 ACP(Agent Client Protocol)卡片: - `task_output(task_id, wait?, timeout_ms?)` 读取输出,并始终追加 `[status: ...]`。流式任务只返回上次读取以来的输出;最终输出任务在结算后返回结果。除非指定 `wait: true`,否则读取不会阻塞;等待超时由插件配置提供默认值并限定上限。等待超时会报告仍在运行的状态,不会停止任务。 - `task_list()` 将调用方可见的任务返回为 `<id> [<kind>] <status> — <label>`,没有任务时返回 `(no background tasks)`。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index 99c7cdeb5b..9375a02315 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -3,4 +3,4 @@ # 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: c36a5a1566b60d1a990174ea268ca529ab1516d3 +2026-06-15-code-mode.zh.md: 0ba5d69258bce23a24db9a0b470ddb074840cba7 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index c36a5a1566..0ba5d69258 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -62,7 +62,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 - `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 }`——程序执行结果解析为 `error` 字段。`run()` 仅在调用方/seam 误用时才 reject(例如重复的绑定命名空间);消费方仍在自己的错误边界处理不合规的后端拒绝。 +- `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`)。 From 46a71ecf22a4a4a67f81d5f1061506b61a149372 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:21:48 +0800 Subject: [PATCH 092/113] docs(i18n): address final core-data review --- docs/core-data-structures/compaction.i18n.yaml | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- docs/core-data-structures/session.i18n.yaml | 2 +- docs/core-data-structures/session.zh.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 4140fb9cc5..3f192f0194 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: 0949b1cb62e4010cf0172b72aa3790b3f4a2acd6 +compaction.zh.md: 35e9c9ef0050f01c5249d1502bb2782511acc819 diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 0949b1cb62..35e9c9ef00 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -1,4 +1,4 @@ -# 上下文压缩(context compaction) +# 压缩(compaction) [English](compaction.md) | 中文 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index ab8a534829..ba86de4263 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session.md: b342e1c5c3bff030d67c61a6f1daa0c8167182c1 -session.zh.md: 8e8a3f923b2f4ec1dd5d86ebe6bb7eff0511474c +session.zh.md: 4f539155a1a035a3fb9807c6990ab10b72c7a421 diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 8e8a3f923b..4f539155a1 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -8,7 +8,7 @@ ## `SessionEventMap`:事件词汇 -仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[上下文压缩(context compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件,用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。 +仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[压缩(compaction) seam](compaction.md) 添加了 `compact/start` / `compact/summary` / `compact/end`,`@deepseek-ai/dsh-hook-protocol` 添加了仅记录日志的 `hook/invoked` / `hook/result` 溯源事件,用于钩子桥接。与 `compact/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。 ```ts type-equiv /** Shared payload for ordinary and steering prompt messages. */ From 2ae9f4fdf3a0087d4dc90b14a71486af676a8a0e Mon Sep 17 00:00:00 2001 From: NI0317 <stniii317@gmail.com> Date: Fri, 24 Jul 2026 12:31:26 +0800 Subject: [PATCH 093/113] feat(tui): add safe session resume flow --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 34 +- .../2026-07-21-tui-resume-command.zh.md | 34 +- apps/cli/README.md | 2 +- apps/cli/package.json | 4 +- apps/cli/src/tui.ts | 37 +- apps/cli/tsconfig.json | 3 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/config-catalog.md | 25 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 38 +- docs/core-data-structures/persistence.md | 12 + docs/core-data-structures/session-query.md | 12 +- docs/event-producer-consumer.md | 2 +- examples/tui-agent/README.md | 2 +- .../tests/fixtures/tui-scripted.cordis.yml | 2 + .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 50 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 + packages/core/agent-loop/src/index.ts | 31 +- packages/core/agent-loop/tests/resume.spec.ts | 45 ++ packages/examples/tui-demo/README.md | 3 +- .../session-persistence-jsonl/README.md | 8 +- .../session-persistence-jsonl/src/index.ts | 122 +++- .../tests/fixtures/live-lease-child.ts | 16 + .../tests/fixtures/live-lease-race-child.ts | 33 + .../tests/jsonl.spec.ts | 182 ++++- .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 67 +- .../session-persistence-sqlite/src/schema.ts | 11 +- .../tests/sqlite.spec.ts | 35 +- .../session-persistence/README.md | 8 +- .../session-persistence/src/coordinator.ts | 86 ++- .../session-persistence/src/index.ts | 38 ++ .../session-persistence/src/lease.ts | 98 +++ .../session-persistence/tests/lease.spec.ts | 62 ++ .../tests/persistence.spec.ts | 58 +- .../session-query/session-query/README.md | 1 + .../session-query/session-query/src/index.ts | 18 +- .../session-query/session-query/src/types.ts | 8 + .../session-query/tests/session-query.spec.ts | 19 + packages/ui/app-boot/README.md | 3 +- packages/ui/app-boot/src/index.ts | 19 +- packages/ui/app-boot/tests/app-boot.spec.ts | 24 +- packages/ui/tui/README.md | 9 +- packages/ui/tui/package.json | 9 + packages/ui/tui/src/index.ts | 404 ++++++++++- packages/ui/tui/tests/harness.ts | 32 +- packages/ui/tui/tests/plugin-shape.spec.ts | 1 + .../snapshots/resume-sessions.expected.txt | 69 +- packages/ui/tui/tests/tui.snapshot.ts | 25 +- packages/ui/tui/tests/tui.spec.ts | 634 ++++++++++++++++-- packages/ui/tui/tsconfig.json | 6 + pnpm-lock.yaml | 9 + scripts/gen-cordis-catalog.ts | 2 + scripts/type-equiv.manifest.json | 10 + 57 files changed, 2312 insertions(+), 192 deletions(-) create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts create mode 100644 packages/session-persistence/session-persistence/src/lease.ts create mode 100644 packages/session-persistence/session-persistence/tests/lease.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 210215eb3d..42370c5dad 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 2282eaa9bff83fdb75bdce315d6b17bf8f9ea303 -2026-07-21-tui-resume-command.zh.md: f9d989a5b4e7eb106ff21c5a4fcfa770a5962343 +2026-07-21-tui-resume-command.md: 23755696a9b7b379f0341c472769684839b37211 +2026-07-21-tui-resume-command.zh.md: cd2e19a2ef95409e8e11199f08afa996e8b07414 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 2282eaa9bf..23755696a9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -1,4 +1,4 @@ -# Agent Note: Resume command hint and `/resume` +# Agent Note: Product-level TUI session resume Status: implemented @@ -6,36 +6,36 @@ English | [中文](2026-07-21-tui-resume-command.zh.md) ## Problem -The TUI can resume a session by launch (`RESUME_SESSION_ID=<id> dsh` feeding `dsh-tui-demo`'s `resumeSessionId`), but nothing told the user the command. On exit the session id survived only in the log and `./.sessions` filenames — the [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the last place it was shown — so resuming meant hunting for the id and reconstructing the invocation. There was also no in-session way to see which sessions in this workspace are resumable. +The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, detect another live owner, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement. ## Decision -A single optional `resumeCommand` config field on `dsh-tui` gates both surfaces: a shell command template whose every `{session}` is replaced with the live session id (e.g. `dsh --resume {session}`). Absent, neither surface appears. +`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and another live owner's session remain visible but disabled. -- **Exit hint.** Process-exiting shutdown prints `To resume this session: <command>` (muted label) via `runtime.terminal.write` after `ui.stop()`, before `runtime.exit`. It prints only once the session is durably persisted: `currentResumeCommand()` scans the session list for the current id and returns `undefined` if it is absent, so a session abandoned before its first flush advertises no command that would fail to load. -- **`/resume`.** Lists this workspace's persisted sessions newest-first, each with its resume command, marking the current one `(current)`. It warns when `resumeCommand` is unconfigured or no persistence backend is mounted, and notes when nothing is persisted yet. The listing is asynchronous, so the transcript updates a tick after submit. -- **Listing.** `listWorkspaceSessions()` reads the optional `sessionPersistence` service's `list()`, keeps headers whose `cwd === agent.session.header.cwd`, and sorts by `createdAt` descending. A `list()` rejection is swallowed to `[]` — a persistence failure must never block terminal exit or crash `/resume`. +`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly. -`sessionPersistence` is an optional injected service reached through `ctx.get('sessionPersistence')` (not `inject`), declared as an optional peer dependency. Without a backend the field still parses; the exit hint and `/resume` degrade to nothing and the unconfigured/no-backend warnings respectively. `dsh-tui-demo` forwards `resumeCommand` to `dsh-tui`, and the runnable `examples/tui-agent` leaves set `dsh --resume {session}`. The `dsh` CLI (`apps/cli`) parses that `--resume <id>` flag through `parseResumeArg` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md), setting `RESUME_SESSION_ID` before boot so the printed command runs back through the config's existing `resumeSessionId` intake; a mistyped or repeated flag fails loud rather than silently starting fresh. +First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID, and release only after the exact session lifecycle drains. `AgentLoop.resume()` claims before load, closing the preflight/start race. + +After preflight, the TUI flushes the current session and stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. + +`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. ## Alternatives considered -**Hardcode or auto-detect the resume invocation.** Rejected: the launch command is deployment-specific — the env-var name, binary, and flags all vary — so a `DEFAULT_*` constant would be a fixed tunable, not configurability. A template owned by the leaf keeps the choice where the deployment lives, and `{session}` is the only substitution the TUI must know. +**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. -**Two config fields, one per surface.** Rejected: both render the identical command, so one field keeps them symmetric and unable to drift; there is no deployment that wants the hint but not the listing. +**Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path. -**Print the exit hint unconditionally.** Rejected: resuming a session id that never flushed fails to load, so advertising it is a broken instruction. Gating on the id appearing in `list()` costs one scan and only ever suppresses a dead command. +**Treat a missing adapter as a missing session.** Rejected: storage validity and current route availability are independent facts. The selector keeps the row and names the unavailable provider/model. -**Resume in place from `/resume` (relaunch or reattach).** Rejected: the TUI does not own agent lifecycle or process spawning ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). Printing a copyable command respects that boundary and matches the `pi --resume` affordance the request cited. - -**Make `sessionPersistence` a required `inject`.** Rejected: the TUI must run without persistence (fixtures, ephemeral runs). An optional service that degrades preserves that, and matches the [`session-query`](../../../../packages/session-query/session-query/package.json) precedent for the same optional peer. +**Persist goal activation across resume.** Rejected: durable intent is not authorization to continue after a human or process boundary. Goal phase survives; automatic continuation does not. ## Consequences -- `dsh-tui` gains an optional peer dependency on `@deepseek-ai/dsh-session-persistence` (`peerDependenciesMeta.optional`), matching `session-query`; the package still loads and passes its coverage gate without a backend mounted. -- The help line and autocomplete gain `/resume`; two existing snapshots re-recorded for the wider help line, and a new `resume-sessions` checkpoint pins the rendered listing. -- `dsh-tui-demo` and both `examples/tui-agent` leaves carry `resumeCommand`, so a real TUI run now prints its own resume command on exit, and the `dsh` CLI accepts the printed `--resume <id>` flag to run it. +- Persistence schema and artifact layout include live leases; SQLite advances its unreleased schema version and rejects older databases under the repository's pre-release policy. +- `/resume` depends on `session-query` for discovery and complete-log reads, but persistence and host handoff remain optional; without a host, the command fallback stays usable. +- Process replacement intentionally restarts Loader composition. Runtime-only state is rebuilt, while only logged or header-backed session state survives. ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins the seven behaviors: the exit hint prints only when the current session is persisted, is omitted when it is not and when `list()` rejects; `/resume` lists workspace sessions newest-first with the `(current)` marker and cwd filter, warns when unconfigured and when no backend is mounted, and notes when nothing is persisted. The `resume-sessions` snapshot verifies the full rendered frame. The harness provides a fake `sessionPersistence` through `ctx.provide`. For the `--resume` flag, `packages/ui/app-boot/tests/app-boot.spec.ts` pins `parseResumeArg` (space and inline forms, position independence, and the fail-loud on a valueless, empty, or repeated flag), and `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots `apps/cli` with `--resume <missing-id>` and asserts the config resume fails loud — proving the flag reaches the `resumeSessionId` intake. +TUI tests cover keyboard navigation, title/id search, Escape cancellation, running-agent refusal, route absence, occupied and corrupt rows, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Persistence contracts retain valid/corrupt/interrupted behavior, while a real JSONL child process proves another owner is disabled and its crashed lease is reclaimed. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index f9d989a5b4..cd2e19a2ef 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Resume command hint and `/resume` +# Agent Note: 产品级 TUI 会话恢复 Status: implemented @@ -6,36 +6,36 @@ Status: implemented ## Problem -TUI 本就能通过启动参数恢复会话(`RESUME_SESSION_ID=<id> dsh` 喂给 `dsh-tui-demo` 的 `resumeSessionId`),但没有任何地方告诉用户这条命令。退出时会话 id 只残留在会话日志和 `./.sessions` 文件名里——[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md) 移除了它最后一处显示位置——因此恢复意味着先翻出 id 再拼回调用命令。也没有任何会话内的方式查看当前 workspace 里哪些会话可恢复。 +原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失、发现另一个活跃所有者,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。 ## Decision -`dsh-tui` 上一个可选的 `resumeCommand` 配置字段同时管辖两处出口:一个 shell 命令模板,其中每一处 `{session}` 都会被替换为当前会话 id(例如 `dsh --resume {session}`)。未设置时两处都不出现。 +`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和被另一个活跃进程占用的会话仍会显示,但不可选择。 -- **退出提示。** 以退出进程方式关闭时,在 `ui.stop()` 之后、`runtime.exit` 之前,经由 `runtime.terminal.write` 打印 `To resume this session: <command>`(弱化的标签)。仅当会话已持久化时才打印:`currentResumeCommand()` 在会话列表中查找当前 id,若不存在则返回 `undefined`,因此在首次刷盘前就被放弃的会话不会宣传一条注定加载失败的命令。 -- **`/resume`。** 按最新在前列出当前 workspace 里已持久化的会话,每条附带其恢复命令,并给当前会话标注 `(current)`。当 `resumeCommand` 未配置或未挂载持久化后端时给出告警,尚无任何会话被持久化时给出提示。列出是异步的,因此提交后文本记录会在下一个 tick 更新。 -- **列出逻辑。** `listWorkspaceSessions()` 读取可选的 `sessionPersistence` 服务的 `list()`,保留 `cwd === agent.session.header.cwd` 的头部,并按 `createdAt` 降序排序。`list()` 拒绝时吞掉为 `[]`——持久化失败绝不能阻塞终端退出或让 `/resume` 崩溃。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 -`sessionPersistence` 是一个通过 `ctx.get('sessionPersistence')`(而非 `inject`)获取的可选注入服务,声明为可选的对等依赖(peer dependency)。没有后端时该字段仍能解析;退出提示与 `/resume` 分别退化为不做任何事、以及给出未配置/无后端告警。`dsh-tui-demo` 将 `resumeCommand` 转发给 `dsh-tui`,可运行的 `examples/tui-agent` 叶子配置设为 `dsh --resume {session}`。`dsh` CLI(`apps/cli`)通过 [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `parseResumeArg` 解析该 `--resume <id>` 标志,在启动前设置 `RESUME_SESSION_ID`,因此打印出的命令会重新走回配置中既有的 `resumeSessionId` 入口;拼写错误或重复的标志会直接报错退出,而非悄悄开启一个新会话。 +第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 的租约,并且仅在对应会话生命周期完全停稳后释放租约。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 + +预检通过后,TUI 先刷写当前会话并停止终端,再调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 + +`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 ## Alternatives considered -**硬编码或自动探测恢复调用命令。** 否决:启动命令与部署强相关——环境变量名、可执行文件、参数都各不相同——因此一个 `DEFAULT_*` 常量只会是固定的可调项,而非可配置项。由叶子拥有的模板把这个选择留在部署所在之处,而 `{session}` 是 TUI 唯一需要知道的替换。 +**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 -**两个配置字段,每处出口一个。** 否决:两处渲染的是完全相同的命令,因此单个字段让它们保持对称、不会漂移;不存在只想要提示而不想要列表的部署。 +**在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。 -**无条件打印退出提示。** 否决:恢复一个从未刷盘的会话 id 会加载失败,宣传它就是一条错误指令。以 id 是否出现在 `list()` 中为条件仅需一次扫描,且只会抑制一条注定失败的命令。 +**把适配器缺失视为会话缺失。** 否决:存储有效性和当前路由可用性是相互独立的事实。选择器会保留该行,并指出不可用的提供方/模型。 -**从 `/resume` 就地恢复(重启或重连)。** 否决:TUI 不拥有 agent 生命周期或进程创建([全屏 TUI 门面 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。打印一条可复制的命令尊重这条边界,也契合需求所引用的 `pi --resume` 用法。 - -**把 `sessionPersistence` 设为必需的 `inject`。** 否决:TUI 必须能在无持久化时运行(fixture(测试前置数据)、临时运行)。一个会优雅退化的可选服务保住了这一点,也与 [`session-query`](../../../../packages/session-query/session-query/package.json) 对同一可选对等依赖的先例一致。 +**恢复会话时延续目标激活状态。** 否决:持久意图并不代表跨越用户或进程边界后仍获授权继续执行。目标阶段会保留,但不会自动续跑。 ## Consequences -- `dsh-tui` 新增对 `@deepseek-ai/dsh-session-persistence` 的可选对等依赖(`peerDependenciesMeta.optional`),与 `session-query` 一致;未挂载后端时该包仍能加载并通过其覆盖率门禁。 -- 帮助行和自动补全新增 `/resume`;两个既有快照因帮助行变宽而重新录制,新增的 `resume-sessions` 检查点固定渲染出的列表。 -- `dsh-tui-demo` 及两个 `examples/tui-agent` 叶子配置都带上 `resumeCommand`,因此真实的 TUI 运行现在退出时会打印自己的恢复命令,且 `dsh` CLI 接受打印出的 `--resume <id>` 标志来运行它。 +- 持久化 schema 和产物布局均包含活跃会话租约;SQLite 会推进其尚未发布的 schema 版本,并根据仓库的预发布政策拒绝旧数据库。 +- `/resume` 依赖 `session-query` 发现会话并读取完整日志,但持久化和宿主交接仍是可选功能;没有宿主时,命令回退仍可使用。 +- 进程替换会有意重启 Loader 组合。系统会重建仅存在于运行时的状态,而只有日志或会话头部记录的会话状态能够保留。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定这七种行为:退出提示仅在当前会话已持久化时打印,未持久化时以及 `list()` 拒绝时都不打印;`/resume` 按最新在前列出 workspace 会话并带 `(current)` 标注与 cwd 过滤、未配置时告警、无后端时告警、尚无持久化时给出提示。`resume-sessions` 快照验证完整渲染帧。测试脚手架通过 `ctx.provide` 提供一个假的 `sessionPersistence`。对于 `--resume` 标志,`packages/ui/app-boot/tests/app-boot.spec.ts` 固定 `parseResumeArg`(空格形式与内联形式、位置无关性,以及在标志缺值、为空或重复时直接报错退出),`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 用 `--resume <missing-id>` 启动 `apps/cli` 并断言配置恢复直接报错退出——证明该标志抵达了 `resumeSessionId` 入口。 +TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、agent 运行期间拒绝恢复、路由缺失、被占用或损坏的候选行、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。持久化契约继续覆盖有效、损坏和中断的会话;真实 JSONL 子进程则证明另一个所有者占用的会话不可选择,并且进程崩溃后遗留的租约可以回收。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 diff --git a/apps/cli/README.md b/apps/cli/README.md index f830b4647d..86bda3fbf5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -5,7 +5,7 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are pro The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume <session-id>` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero; +- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and atomically replaces the process with a normalized resume flag so only one runtime owns the terminal; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. diff --git a/apps/cli/package.json b/apps/cli/package.json index d7942c1e2d..8557965d34 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -29,6 +29,8 @@ "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", + "cordis": "^4.0.0-rc.7" } } diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6f97a68ad3..4a4ca8d7fc 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -19,9 +19,12 @@ import { loadEnv, loadPersonalPatches, parseResumeArg, + replaceResumeArg, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { Context } from 'cordis' +import type { TuiResumeHost } from '@deepseek-ai/dsh-tui' const NAME = 'dsh' @@ -65,7 +68,39 @@ export async function runTui(argv: string[]): Promise<void> { // after loadEnv and before boot reads it through the config's `!!js`. const { resumeSessionId, rest } = parseResumeArg(argv) if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId - const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) + const entry = process.argv[1] + const execve = process.execve?.bind(process) + const app: { current?: Context } = {} + const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { + async handoff(sessionId): Promise<never> { + const current = app.current + if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) + const nextArgv = [ + process.execPath, + ...process.execArgv, + entry, + ...replaceResumeArg(process.argv.slice(2), sessionId), + ] + process.env[RESUME_SESSION_ID_ENV] = sessionId + try { + await current.fiber.dispose() + execve(process.execPath, nextArgv, process.env) + throw new Error('process replacement returned unexpectedly') + } catch (error) { + process.stderr.write(`${NAME}: resume handoff failed after terminal release: ${String(error)}\n`) + process.exit(1) + } + }, + } + const ctx = await boot( + NAME, + resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), + loadPersonalPatches(NAME), + (hostCtx) => { + if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) + }, + ) + app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) } /* v8 ignore stop */ diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index cbc786d4c6..b33280943a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/ui/tui" + }, { "path": "../../packages/util/paths" }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..ea83179477 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 -architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee +architecture.md: f0ce115d0b6e07a14d3c28288ea78f2c2f4294e7 +architecture.zh.md: eef66e3b9df6a3f00a48fc2c6d2e942b3fa9fe42 diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..f0ce115d0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,7 @@ The shipped loop runs prompt-to-checkpoint work through plugin services and even A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. -Without an id, creation mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. +Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume claims a live lease before load, restores lineage and delegation depth before publication, and releases after quiescence. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent. ### Turn Flow @@ -147,7 +147,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +`ctx.sessions.appendOutOfBand()` joins log-only events to an open turn or creates a flushed zero-step turn. `session/title` folds latest-wins with source seqs/provenance; fallback and its optional provider never delay responses. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..eef66e3b9d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -67,7 +67,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 -未提供 id 时,创建流程会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 +未提供 id 时会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程在加载前领取活跃会话租约,在发布前还原沿袭关系和委托深度,并在系统停稳后释放租约。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 ### 轮次流程 @@ -147,7 +147,7 @@ forever: 持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +`ctx.sessions.appendOutOfBand()` 会把纯日志事件加入开放轮次,或创建一个已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq/来源信息;回退标题及其可选提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 985e493184..6b841214c5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -113,7 +113,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:378`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -957,7 +957,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:39`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -996,7 +996,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:59`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query-sqlite` @@ -1571,7 +1571,7 @@ Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` +Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1581,11 +1581,10 @@ export interface Config extends TuiConfig { /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** - * Shell command template shown for resuming this session: printed on exit and - * listed by `/resume`, with every `{session}` occurrence replaced by the live - * session id. Absent disables both surfaces. Deployments set it only when a - * persistence backend makes the session resumable (e.g. - * `RESUME_SESSION_ID={session} dsh`). + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. */ resumeCommand?: string } @@ -1600,6 +1599,8 @@ export interface TuiConfig { maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -1608,6 +1609,10 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Resume-selector width in terminal columns. */ + resumeDialogWidth?: number + /** Resume-selector maximum height in terminal rows. */ + resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -1630,7 +1635,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:248`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:278`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..9bda12f6b0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -399,7 +399,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:371`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5d6d88d5c..5b3f7c4afb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandl Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:398`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:416`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -961,11 +961,29 @@ abstract list(): Promise<SessionHeader[]> * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]> + +/** + * Atomically acquire this process's live ownership of a session id. + * Reentrant claims share one backend lease. First-party backends override + * this process-local fallback to reject another live process and reclaim a + * dead owner. + * @param id - session identity that is about to become live. + * @returns a single-release reference owned by the caller. + */ +claimLive(id: SessionId): Promise<SessionLiveLease> + +/** + * Check whether any process currently owns a live lease for this session. + * The base implementation reports only claims on this service instance. + * @param id - persisted or prospective session identity. + * @returns true while a non-stale lease exists, including this process's lease. + */ +isLive(id: SessionId): Promise<boolean> ``` -Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) +Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:55`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) @@ -996,6 +1014,14 @@ abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchE */ listSessions(): Promise<SessionRecord[]> +/** + * Read and replay-validate one complete logical session log without making it live. + * @param sessionId - live or persisted session id to read. + * @returns cloned header and complete raw event log from one observation. + * @throws when persistence, header compatibility, or replay validation fails. + */ +async readSession(sessionId: SessionId): Promise<SessionLogSnapshot> + /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. @@ -1057,9 +1083,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace> async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:73`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` @@ -1701,7 +1727,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:132`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f45eb0417a..ffd4304376 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -34,6 +34,18 @@ interface SessionLocation { } ``` +## `SessionLiveLease` — live ownership capability + +`claimLive(id)` returns one idempotent release capability. The base service tracks only its own process; first-party backends additionally reject another live process and reclaim a dead owner's lease. `isLive(id)` reports either local or backend ownership without claiming it. + +```ts type-equiv +/** Idempotent capability releasing one acquired live-session lease reference. */ +interface SessionLiveLease { + /** Release this caller's lease reference after its live session reaches quiescence. */ + release(): Promise<void> +} +``` + ## `SessionHeader` — metadata beside the log Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 0fe1596aaf..25315e3166 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -25,7 +25,17 @@ interface SessionRecord { } ``` -`SessionSurfaceSnapshot` is one exact-read observation rather than a retained subscription. Its raw-log boundary and folded events come from the same live-preferred load. +`SessionLogSnapshot` is the complete detached, replay-validated raw log used by resume preflight. `SessionSurfaceSnapshot` is one exact-read surface observation rather than a retained subscription. + +```ts type-equiv +/** One validated detached observation of a logical session's complete raw log. */ +interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} +``` ```ts type-equiv /** One atomic live-preferred observation of a session's current model surface. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..7f7ecc4f2d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:371`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 9522fb4979..032a82bdaf 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume <prior-session-id> ``` -The TUI prints this exact command on exit and lists it under `/resume`, so resuming is copy-paste. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then atomically replaces the process with `dsh --resume <id>`; the terminal never has two owners. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. ## Code Mode diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index e4548da79e..4668747077 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -29,6 +29,8 @@ # The smoke's log inspection reads plain `.jsonl`; keep the scripted # fixture uncompressed like the other snapshot-facing configs. persistenceCompression: none + resumeSessionId: !!js process.env.RESUME_SESSION_ID + resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 66697673ba..9c198870ad 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,8 +1,10 @@ -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) @@ -44,6 +46,31 @@ function seedWorkspace( } } +/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */ +async function seedResumeSession(cwd: string): Promise<void> { + const sessionCwd = await realpath(cwd) + const id = SessionId('resume-target') + const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd } + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 1_700_000_000_002, data: { content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: 1_700_000_000_003, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: 1_700_000_000_004, data: { header: { config: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: 1_700_000_000_005, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'persisted answer' }], provenance: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } }, + { type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } }, + { type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } }, + { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none') + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, [ + JSON.stringify(toHeaderLine(meta)), + ...events.map(event => JSON.stringify(event)), + '', + ].join('\n')) +} + /** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ async function readLoggedSystemPrompt(cwd: string): Promise<string> { const sessionsDir = join(cwd, '.sessions') @@ -233,6 +260,27 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { + it('hands /resume to one exec-replaced terminal owner and restores the same session state', async () => { + const output = await smoke({ + label: 'dsh in-place resume', + tempDirPrefix: 'dsh-in-place-resume-', + binScript: dshBinScript, + configArgs: [scriptedConfigPath], + prepare: seedResumeSession, + actions: [ + { waitFor: 'scripted TUI ready.', send: '/resume\r' }, + { waitFor: 'Resume selector design', send: 'Resume selector design' }, + { waitFor: 'Search: Resume selector design', send: '\r' }, + { waitFor: 'Preserve restored state', send: '/exit\r' }, + ], + }) + const released = output.indexOf('\u001B[?2004l') + const restored = output.indexOf('Resume selector design — DeepSeek Harness') + expect(released).toBeGreaterThanOrEqual(0) + expect(restored).toBeGreaterThan(released) + expect(output).toContain('Preserve restored state') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('boots the shipped default config with no arguments and no personal overlay', async () => { const output = await smoke({ label: 'dsh default boot', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ffc1670f4e..f49cc75b1c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -480,6 +480,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>', jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */', }, + { + signature: 'claimLive(id: SessionId): Promise<SessionLiveLease>', + jsDoc: '/**\n * Atomically acquire this process\'s live ownership of a session id.\n * Reentrant claims share one backend lease. First-party backends override\n * this process-local fallback to reject another live process and reclaim a\n * dead owner.\n * @param id - session identity that is about to become live.\n * @returns a single-release reference owned by the caller.\n */', + }, + { + signature: 'isLive(id: SessionId): Promise<boolean>', + jsDoc: '/**\n * Check whether any process currently owns a live lease for this session.\n * The base implementation reports only claims on this service instance.\n * @param id - persisted or prospective session identity.\n * @returns true while a non-stale lease exists, including this process\'s lease.\n */', + }, ], }, { @@ -498,6 +506,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'listSessions(): Promise<SessionRecord[]>', jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */', }, + { + signature: 'async readSession(sessionId: SessionId): Promise<SessionLogSnapshot>', + jsDoc: '/**\n * Read and replay-validate one complete logical session log without making it live.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned header and complete raw event log from one observation.\n * @throws when persistence, header compatibility, or replay validation fails.\n */', + }, { signature: 'async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]>', jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */', @@ -1805,10 +1817,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLineageTrace', declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', }, + { + name: 'SessionLiveLease', + declaration: 'export interface SessionLiveLease {\n release(): Promise<void>;\n}', + }, { name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', }, + { + name: 'SessionLogSnapshot', + declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n events: SessionEvent[];\n}', + }, { name: 'SessionPersistenceRevision', declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bdaa3f2401..15ece492e3 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -25,7 +25,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { bindReactLoopAgentContext, prepareReactLoopAgent, @@ -114,6 +114,7 @@ class AgentCreationTransaction { private scope: Scope | undefined private session: Session | undefined private lifecycleDispose: (() => Promise<void> | void) | undefined + private liveLease: SessionLiveLease | undefined private detachSession: (() => void) | undefined private detachAgent: (() => void) | undefined private publishing = false @@ -186,6 +187,12 @@ class AgentCreationTransaction { ]) } + /** Retain a pre-load persistence lease until this transaction fully tears down. */ + holdLiveLease(lease: SessionLiveLease): void { + this.assertActive() + this.liveLease = lease + } + /** Construct the driver and scope, then install their complete ordered lifecycle. */ prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { this.assertActive() @@ -219,6 +226,11 @@ class AgentCreationTransaction { // First yielded, disposed last. yield () => { this.finish() } yield scope.rawDispose + yield async () => { + const lease = this.liveLease + this.liveLease = undefined + await lease?.release() + } yield () => { this.detachSession?.() this.detachSession = undefined @@ -315,7 +327,13 @@ class AgentCreationTransaction { try { await this.scope?.dispose() } finally { - this.finish() + try { + const lease = this.liveLease + this.liveLease = undefined + await lease?.release() + } finally { + this.finish() + } } } })()) @@ -607,6 +625,15 @@ export class AgentLoop extends Service implements AgentFactory { options.signal, ) try { + const claiming = persistence.claimLive(options.resumeSessionId) + let lease: SessionLiveLease + try { + lease = await transaction.waitFor(claiming) + } catch (error) { + void claiming.then(claim => claim.release(), () => {}) + throw error + } + transaction.holdLiveLease(lease) const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId)) transaction.assertActive() const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index fdf5c39514..2d19db7a0d 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -391,6 +391,51 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('owner unload during live-lease acquisition releases a late claim', async () => { + const sessionId = SessionId('resume-claim-owner-unload') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const claiming = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.claimLive>>>() + const claimStarted = Promise.withResolvers<undefined>() + const originalClaim = ctx.sessionPersistence.claimLive.bind(ctx.sessionPersistence) + ctx.sessionPersistence.claimLive = (id) => { + expect(id).toBe(sessionId) + claimStarted.resolve(undefined) + return claiming.promise + } + + let resuming!: ReturnType<typeof ctx.agents.resume> + const owner = await ctx.plugin(Object.assign((inner: Context) => { + resuming = inner.agents.resume({ resumeSessionId: sessionId }) + }, { inject: ['agents'] })) + await claimStarted.promise + const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) + await promptly(owner.dispose()) + await rejection + + let releases = 0 + claiming.resolve({ release: () => { releases += 1; return Promise.resolve() } }) + await Promise.resolve() + await Promise.resolve() + expect(releases).toBe(1) + ctx.sessionPersistence.claimLive = originalClaim + await ctx.fiber.dispose() + }) + + it('propagates a rejected live-lease claim without loading or publishing', async () => { + const sessionId = SessionId('resume-claim-rejected') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + let loads = 0 + ctx.sessionPersistence.claimLive = () => Promise.reject(new Error('occupied elsewhere')) + ctx.sessionPersistence.load = () => { loads += 1; return Promise.reject(new Error('must not load')) } + await expect(ctx.agents.resume({ resumeSessionId: sessionId })) + .rejects.toThrow('occupied elsewhere') + expect(loads).toBe(0) + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const root = await persistSession(sessionId) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index a20a0fae9d..bba7ae7f7e 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -41,10 +41,11 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | | `welcome` | `ready.` | TUI subtitle | +| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff | | `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | | `resumeSessionId` | — | Exact persisted session to resume | -Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. +Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for safe in-place process handoff. ## The bin diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index bf86bf8633..4aaf30f45a 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,6 +6,9 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` <root>/ + .live/ + <encoded-id>.lock # PID + nonce cross-process live lease + <encoded-id>.lock.reclaim # ephemeral stale-owner takeover guard cwd-<sha256(cwd)[:12]>/ # per-project bucket (or _no-cwd/ when no cwd) <encoded-id>.jsonl.zstd # default: checksummed header frame + append frames <encoded-id>.jsonl # only with compression: 'none' @@ -43,7 +46,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the ## Write path -The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown. +The plugin copies frozen session events into one controller per live session and starts an eager drain. Before a session can flush or resume, the coordinator claims an exclusive `.live/<encoded-id>.lock` containing the process PID and an exec-stable nonce; another live process is rejected, while a dead owner is reclaimed under the separate `.reclaim` guard. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Disposal drains every retained controller before releasing its lease. ## Model Experience @@ -66,5 +69,6 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement. +- **Lease scope is local-host advisory ownership** — PID liveness prevents two ordinary local Harness processes from resuming the same id, but it is not a distributed lease for shared network filesystems or hostile principals. +- **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 629c0e3ff1..a3ed611a4c 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,8 +14,9 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + sessionLeaseProcessIsLive, shareSessionLiveLease, + type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, + type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -64,6 +65,11 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } +interface JsonlLiveLeaseRecord { + pid: number + nonce: string +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' @@ -135,6 +141,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.inspect(id) } + override claimLive(id: SessionId): Promise<SessionLiveLease> { + return this.coordinator.claimLive(id) + } + + override isLive(id: SessionId): Promise<boolean> { + return this.coordinator.isLive(id) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -274,6 +288,110 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return snapshots } + /** Atomically publish one process lease, reclaiming a crashed owner's record. */ + async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> { + const path = this.liveLeasePath(id) + return shareSessionLiveLease(`jsonl:${path}`, () => this.acquireLiveFile(path, id, owner)) + } + + private async acquireLiveFile( + path: string, + id: SessionId, + owner: SessionLiveOwner, + ): Promise<() => Promise<void>> { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + for (;;) { + try { + const handle = await open(path, 'wx', 0o600) + try { + await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + const current = await this.readLiveLease(path) + if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break + if (current === undefined || sessionLeaseProcessIsLive(current.pid)) { + throw new Error(`session "${id}" is occupied by another live process`) + } + const reclaimPath = `${path}.reclaim` + let reclaim: Awaited<ReturnType<typeof open>> + try { + reclaim = await open(reclaimPath, 'wx', 0o600) + } catch (reclaimError) { + /* v8 ignore else -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ + if ((reclaimError as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error(`session "${id}" live-lease reclamation is already in progress`) + } + /* v8 ignore next -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ + throw reclaimError + } + try { + /* v8 ignore start -- cross-process revalidation is covered by the two-process race test */ + const latest = await this.readLiveLease(path) + if (latest === undefined) { + if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`) + } else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) { + if (sessionLeaseProcessIsLive(latest.pid)) { + throw new Error(`session "${id}" is occupied by another live process`) + } + await rm(path, { force: true }) + } + /* v8 ignore stop */ + } finally { + try { + await reclaim.close() + } finally { + await rm(reclaimPath, { force: true }) + } + } + } + } + return async () => { + const current = await this.readLiveLease(path) + if (current?.pid === owner.pid && current.nonce === owner.nonce) await rm(path, { force: true }) + } + } + + /** Report one non-stale process lease and clean up a crashed owner's record. */ + async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> { + const path = this.liveLeasePath(id) + const current = await this.readLiveLease(path) + if (current === undefined) return await this.exists(path) + if (current.pid === owner.pid && current.nonce === owner.nonce) return true + if (sessionLeaseProcessIsLive(current.pid)) return true + return false + } + + private liveLeasePath(id: SessionId): string { + return join(this.root, '.live', `${encodeSegment(id)}.lock`) + } + + private async readLiveLease(path: string): Promise<JsonlLiveLeaseRecord | undefined> { + let text: string + try { + text = await readFile(path, 'utf8') + } catch (error) { + if (isENOENT(error)) return undefined + throw error + } + let value: unknown + try { + value = JSON.parse(text) + } catch { + return undefined + } + if (typeof value !== 'object' || value === null + || !Number.isSafeInteger((value as { pid?: unknown }).pid) + || (value as { pid: number }).pid <= 0 + || typeof (value as { nonce?: unknown }).nonce !== 'string' + || (value as { nonce: string }).nonce.length === 0) return undefined + return value as JsonlLiveLeaseRecord + } + private async listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>> { await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts new file mode 100644 index 0000000000..2a42b8c402 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts @@ -0,0 +1,16 @@ +/** Child process that holds one JSONL live-session lease until it is killed. */ + +import { writeFile } from 'node:fs/promises' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' + +const [root, marker] = process.argv.slice(2) +if (root === undefined || marker === undefined) throw new Error('usage: live-lease-child.ts <root> <marker>') + +const ctx = new Context() +await ctx.plugin(SessionStore) +await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) +await ctx.sessionPersistence.claimLive(SessionId('leased-session')) +await writeFile(marker, 'held') +await new Promise<never>(() => { setInterval(() => {}, 60_000) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts new file mode 100644 index 0000000000..b4ef3b6e59 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts @@ -0,0 +1,33 @@ +/** Child process competing to reclaim one stale JSONL live-session lease. */ + +import { access, writeFile } from 'node:fs/promises' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' + +const [root, gate, marker, rawId] = process.argv.slice(2) +if (root === undefined || gate === undefined || marker === undefined || rawId === undefined) { + throw new Error('usage: live-lease-race-child.ts <root> <gate> <marker> <session-id>') +} + +for (;;) { + try { + await access(gate) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +const ctx = new Context() +await ctx.plugin(SessionStore) +await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) +try { + await ctx.sessionPersistence.claimLive(SessionId(rawId)) + await writeFile(marker, 'claimed') + await new Promise<never>(() => { setInterval(() => {}, 60_000) }) +} catch (error) { + await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`) + await ctx.fiber.dispose() +} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 2b49b7d55b..517cf7bb04 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,17 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { Context } from 'cordis' -import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { access, appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' let root: string const dirs: string[] = [] +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const leaseChild = fileURLToPath(new URL('./fixtures/live-lease-child.ts', import.meta.url)) +const leaseRaceChild = fileURLToPath(new URL('./fixtures/live-lease-race-child.ts', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } @@ -142,6 +149,179 @@ describe('SessionPersistenceJsonl: format helpers', () => { }) }) +describe('SessionPersistenceJsonl: cross-process live leases', () => { + it('reference-counts one physical lease across backend instances in the process', async () => { + const dir = await freshRoot() + const contexts = [new Context(), new Context()] + for (const ctx of contexts) { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + } + try { + const first = await contexts[0]!.sessionPersistence.claimLive(SessionId('shared-live')) + const second = await contexts[1]!.sessionPersistence.claimLive(SessionId('shared-live')) + await first.release() + await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(true) + await second.release() + await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(false) + } finally { + await Promise.all(contexts.map(ctx => ctx.fiber.dispose())) + } + }) + + it('disables another live owner and reclaims its lease after the process exits', async () => { + const dir = await freshRoot() + const marker = join(dir, 'lease-held') + const child = spawn(process.execPath, ['--import', tsxLoader, leaseChild, dir, marker], { + cwd: repoRoot, + env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + try { + await vi.waitFor(() => access(marker), { timeout: 30_000 }) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + try { + await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(true) + await expect(ctx.sessionPersistence.claimLive(SessionId('leased-session'))) + .rejects.toThrow('occupied by another live process') + const closed = new Promise<void>(resolve => child.once('close', () => { resolve() })) + child.kill() + await closed + await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(false) + const leasePath = join(dir, '.live', `${encodeSegment('leased-session')}.lock`) + await writeFile(leasePath, `${JSON.stringify({ pid: child.pid, nonce: 'dead-owner' })}\n`) + const claim = await ctx.sessionPersistence.claimLive(SessionId('leased-session')) + await claim.release() + } finally { + await ctx.fiber.dispose() + } + } catch (error) { + throw new Error(`live-lease child failed: ${stderr}`, { cause: error }) + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill() + } + }, 40_000) + + it('fails closed on malformed lease records and surfaces lease read errors', async () => { + const dir = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + const liveDir = join(dir, '.live') + await mkdir(liveDir, { recursive: true }) + try { + const malformed = [ + 'not json', + JSON.stringify(null), + JSON.stringify({ pid: 1.5, nonce: 'x' }), + JSON.stringify({ pid: 0, nonce: 'x' }), + JSON.stringify({ pid: process.pid, nonce: 1 }), + JSON.stringify({ pid: process.pid, nonce: '' }), + ] + for (const [index, content] of malformed.entries()) { + const id = SessionId(`malformed-${index}`) + const path = join(liveDir, `${encodeSegment(id)}.lock`) + await writeFile(path, content) + await expect(ctx.sessionPersistence.isLive(id)).resolves.toBe(true) + await expect(ctx.sessionPersistence.claimLive(id)).rejects.toThrow('occupied by another live process') + } + + const unreadable = SessionId('unreadable-lease') + await mkdir(join(liveDir, `${encodeSegment(unreadable)}.lock`)) + await expect(ctx.sessionPersistence.isLive(unreadable)).rejects.toThrow() + + const replaced = SessionId('replaced-release') + const claim = await ctx.sessionPersistence.claimLive(replaced) + const replacedPath = join(liveDir, `${encodeSegment(replaced)}.lock`) + await writeFile(replacedPath, JSON.stringify({ pid: process.pid, nonce: 'replacement' })) + await claim.release() + expect(await readFile(replacedPath, 'utf8')).toContain('replacement') + + const inherited = SessionId('inherited-owner') + const inheritedPath = join(liveDir, `${encodeSegment(inherited)}.lock`) + await writeFile(inheritedPath, JSON.stringify(sessionLiveOwner())) + await expect(ctx.sessionPersistence.isLive(inherited)).resolves.toBe(true) + const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited) + await inheritedClaim.release() + + await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300)))) + .rejects.toThrow() + + const guarded = SessionId('guarded-reclaim') + const guardedPath = join(liveDir, `${encodeSegment(guarded)}.lock`) + await writeFile(guardedPath, JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' })) + await writeFile(`${guardedPath}.reclaim`, 'busy') + await expect(ctx.sessionPersistence.claimLive(guarded)) + .rejects.toThrow('reclamation is already in progress') + } finally { + await ctx.fiber.dispose() + } + }) + + it('allows exactly one process to reclaim a stale lease', async () => { + const dir = await freshRoot() + const liveDir = join(dir, '.live') + await mkdir(liveDir, { recursive: true }) + const sessionId = SessionId('reclaim-race') + await writeFile( + join(liveDir, `${encodeSegment(sessionId)}.lock`), + JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }), + ) + const gate = join(dir, 'race-start') + const markers = [join(dir, 'race-a'), join(dir, 'race-b')] + const children = markers.map(marker => spawn( + process.execPath, + ['--import', tsxLoader, leaseRaceChild, dir, gate, marker, sessionId], + { + cwd: repoRoot, + env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdio: ['ignore', 'ignore', 'pipe'], + }, + )) + const errors = ['', ''] + children.forEach((child, index) => { + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { errors[index] = (errors[index] ?? '') + chunk }) + }) + try { + await writeFile(gate, 'go') + await vi.waitFor(() => Promise.all(markers.map(marker => access(marker))), { timeout: 30_000 }) + const outcomes = await Promise.all(markers.map(marker => readFile(marker, 'utf8'))) + expect(outcomes.filter(outcome => outcome === 'claimed')).toHaveLength(1) + expect(outcomes.filter(outcome => outcome.startsWith('rejected:'))).toHaveLength(1) + + const winner = children[outcomes.findIndex(outcome => outcome === 'claimed')]! + const loser = children[outcomes.findIndex(outcome => outcome.startsWith('rejected:'))]! + if (loser.exitCode === null && loser.signalCode === null) { + await new Promise<void>(resolve => loser.once('close', () => { resolve() })) + } + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + try { + await expect(ctx.sessionPersistence.claimLive(sessionId)) + .rejects.toThrow('occupied by another live process') + } finally { + await ctx.fiber.dispose() + } + const closed = new Promise<void>(resolve => winner.once('close', () => { resolve() })) + winner.kill() + await closed + } catch (error) { + throw new Error(`live-lease race children failed: ${errors.join('\n')}`, { cause: error }) + } finally { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill() + } + } + }, 40_000) +}) + describe('SessionPersistenceJsonl: durability and crash semantics', () => { let ctx: Context beforeEach(async () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index f1f4bc1f7b..374da93faa 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id, and `live_session_leases` stores one PID and exec-stable nonce per live session. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. @@ -33,7 +33,7 @@ interface Config { ## Write path -Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. +Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. A live lease is acquired in a `BEGIN IMMEDIATE` transaction before flush or resume and released after the exact lifecycle retires. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. ## Model Experience diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 5804c18282..4399ee9c90 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,8 +15,9 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + sessionLeaseProcessIsLive, shareSessionLiveLease, + type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, + type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -161,6 +162,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.inspect(id) } + override claimLive(id: SessionId): Promise<SessionLiveLease> { + return this.coordinator.claimLive(id) + } + + override isLive(id: SessionId): Promise<boolean> { + return this.coordinator.isLive(id) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -271,6 +280,55 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers })) } + /** Atomically acquire one SQLite-backed process lease. */ + async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> { + await this.ready + return shareSessionLiveLease( + `sqlite:${this.storeIdentity}:${id}`, + () => Promise.resolve().then(() => this.acquireLiveRow(id, owner)), + ) + } + + private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise<void> { + this.db.exec('BEGIN IMMEDIATE') + try { + const current = this.liveLeaseFor(id) + if (current !== undefined + && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { + if (sessionLeaseProcessIsLive(current.pid)) { + throw new Error(`session "${id}" is occupied by another live process`) + } + this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id) + } + this.db.prepare(` + INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce + `).run(id, owner.pid, owner.nonce) + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + return async () => { + await this.ready + this.db.prepare( + 'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?', + ).run(id, owner.pid, owner.nonce) + } + } + + /** Report a non-stale SQLite lease and remove a crashed owner's row. */ + async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> { + await this.ready + const current = this.liveLeaseFor(id) + if (current === undefined) return false + if ((current.pid === owner.pid && current.nonce === owner.nonce) + || sessionLeaseProcessIsLive(current.pid)) return true + this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?') + .run(id, current.pid, current.nonce) + return false + } + /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ async close(): Promise<void> { await this.ready @@ -284,6 +342,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined } + private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined { + return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?') + .get(id) as { pid: number; nonce: string } | undefined + } + /** * Insert-or-replace a session's metadata row. The only caller is the first * materializing `appendBatch`, so writing the row IS the materialization (its diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 8b8dcd78e0..6a5be76eb9 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 8 +export const SCHEMA_VERSION = 9 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * rather than being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. - * @returns the open handle with pragmas applied and all three tables ensured. + * @returns the open handle with pragmas applied and all tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) @@ -128,6 +128,13 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM PRIMARY KEY (session_id, seq) ) STRICT `) + db.exec(` + CREATE TABLE IF NOT EXISTS live_session_leases ( + session_id TEXT PRIMARY KEY, + pid INTEGER NOT NULL, + nonce TEXT NOT NULL + ) STRICT + `) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 3976e71549..a3aba22323 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -7,6 +7,7 @@ import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' +import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -442,7 +443,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(8) + expect(SCHEMA_VERSION).toBe(9) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -458,6 +459,38 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('claims, rejects, reclaims, inspects, and releases SQLite live leases', async () => { + const path = await freshDbPath() + const b = await backend(path) + await b.ctx.sessionPersistence.list() + const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite + const owner = sessionLiveOwner() + const db = openDatabase(path, 'wal') + const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') + insert.run('occupied-lease', process.pid, 'another-owner') + insert.run('stale-claim', 2_147_483_647, 'dead-owner') + insert.run('stale-inspect', 2_147_483_647, 'dead-owner') + insert.run('owned-inspect', owner.pid, owner.nonce) + db.close() + + await expect(concrete.acquireLive(SessionId('occupied-lease'), owner)) + .rejects.toThrow('occupied by another live process') + const claim = await concrete.acquireLive(SessionId('stale-claim'), owner) + expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true) + expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false) + expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false) + await claim() + await b.dispose() + + const memory = new Context() + await memory.plugin(SessionStore) + await memory.plugin(SessionPersistenceSqlite, { path: ':memory:' }) + const memoryClaim = await memory.sessionPersistence.claimLive(SessionId('memory-live')) + expect(await memory.sessionPersistence.isLive(SessionId('memory-live'))).toBe(true) + await memoryClaim.release() + await memory.fiber.dispose() + }) + it('rejects and closes a current-schema database with an invalid store identity', async () => { const path = await freshDbPath() const db = openDatabase(path, 'wal') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 25429bd720..2bfa3a31b1 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -15,6 +15,10 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. | | `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | +| `claimLive(id): Promise<SessionLiveLease>` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. | +| `isLive(id): Promise<boolean>` | Report a current non-stale live lease, including one owned by this process. | + +The abstract base supplies a process-local fallback for lightweight third-party implementations. A backend that needs multi-process safety overrides both live-lease methods. ## Invariants every backend must honor @@ -31,7 +35,7 @@ Each `session/event` copies its event into the session controller and starts an Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. +When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state and the backend-owned live lease for that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, releases their leases, awaits per-id operations, and only then closes the storage handle. The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. @@ -44,6 +48,8 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list()` | List all stored metadata. | +| `acquireLive?(id, owner)` | Atomically acquire a backend-owned cross-process lease and return its physical release. | +| `inspectLive?(id, owner)` | Report or reclaim a backend-owned lease without acquiring it. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index fb46aa4877..8ea1790b3e 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -8,6 +8,8 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import { sessionLiveOwner } from './lease.ts' +import type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** * A stored session's header, valid contiguous event prefix, and optional opaque @@ -63,6 +65,12 @@ export interface PersistenceBackend<TornMarker = unknown> { /** List all stored (materialized) sessions' metadata. */ list(): Promise<SessionHeader[]> + /** Optionally acquire a backend-owned cross-process live-session lease. */ + acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> + + /** Optionally inspect and reclaim a backend-owned live-session lease. */ + inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise<boolean> + /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -96,6 +104,7 @@ interface LiveSessionState { pending: SessionEvent[] init: Promise<void> flush: Promise<void> | undefined + lease?: SessionLiveLease } /** Collect the rejection reasons from a set of promises (none-throwing). */ @@ -161,6 +170,12 @@ export class PersistenceCoordinator<TornMarker = unknown> { * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map<SessionId, Promise<unknown>>() + /** One backend lease with process-local reference counting per session id. */ + private liveClaims = new Map<SessionId, { + refs: number + releaseBackend: () => Promise<void> + }>() + private readonly liveOwner = sessionLiveOwner() constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) { this.installWritePath() @@ -273,6 +288,61 @@ export class PersistenceCoordinator<TornMarker = unknown> { return this.serialize(id, () => this.inspectCore(id)) } + /** + * Acquire one process-local reference to the backend's cross-process lease. + * @param id - session identity about to become live. + * @returns one idempotent release capability. + */ + async claimLive(id: SessionId): Promise<SessionLiveLease> { + const acquireLive = this.backend.acquireLive?.bind(this.backend) + if (acquireLive === undefined) return { release: () => Promise.resolve() } + await this.serialize(id, async () => { + const existing = this.liveClaims.get(id) + if (existing !== undefined) { + existing.refs += 1 + return + } + const releaseBackend = await acquireLive(id, this.liveOwner) + this.liveClaims.set(id, { refs: 1, releaseBackend }) + }) + let releaseTask: Promise<void> | undefined + return { + release: () => { + if (releaseTask !== undefined) return releaseTask + const task = this.serialize(id, async () => { + const claim = this.liveClaims.get(id) + /* v8 ignore next -- this capability is returned only after its claim enters the serialized map */ + if (claim === undefined) return + claim.refs -= 1 + if (claim.refs > 0) return + try { + await claim.releaseBackend() + } catch (error) { + claim.refs += 1 + throw error + } + this.liveClaims.delete(id) + }) + const wrapped = task.catch((error: unknown) => { + releaseTask = undefined + throw error + }) + releaseTask = wrapped + return wrapped + }, + } + } + + /** + * Check the backend's current cross-process lease state. + * @param id - session identity to inspect. + * @returns whether this or another live process owns the session. + */ + isLive(id: SessionId): Promise<boolean> { + if (this.liveClaims.has(id)) return Promise.resolve(true) + return this.backend.inspectLive?.(id, this.liveOwner) ?? Promise.resolve(false) + } + private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) @@ -382,6 +452,9 @@ export class PersistenceCoordinator<TornMarker = unknown> { let disposeError: unknown try { const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session))) + errors.push(...await settledErrors( + [...this.live.values()].flatMap(live => live.lease === undefined ? [] : [live.lease.release()]), + )) while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]) if (errors.length > 0) { throw new AggregateError(errors, `${this.backend.name} dispose failed`) @@ -441,6 +514,8 @@ export class PersistenceCoordinator<TornMarker = unknown> { private async retireCore(session: Session): Promise<void> { await this.flush(session) const id = session.header.id + const live = this.live.get(session) + await live?.lease?.release() await this.serialize(id, () => { this.live.delete(session) if (this.states.get(id)?.owner === session) this.states.delete(id) @@ -454,7 +529,16 @@ export class PersistenceCoordinator<TornMarker = unknown> { const seed = session.events.map(e => structuredClone(e)) const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } this.live.set(session, live) - live.init = this.serialize(session.header.id, () => this.onCreated(session, seed)) + live.init = this.claimLive(session.id).then(async (lease) => { + live.lease = lease + try { + await this.serialize(session.header.id, () => this.onCreated(session, seed)) + } catch (error) { + delete live.lease + await lease.release() + throw error + } + }) live.init.catch(() => { /* observed by flush/dispose through the controller */ }) return live } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index c785c9354c..3aa602c8c0 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -8,10 +8,13 @@ import { Context, Service } from 'cordis' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' +import type { SessionLiveLease } from './lease.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' +export { sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease } from './lease.ts' +export type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { @@ -50,6 +53,8 @@ export interface SessionLocation { * rewriting committed events. */ export abstract class SessionPersistence extends Service { + private readonly localLiveClaims = new Map<SessionId, number>() + constructor(ctx: Context) { super(ctx, 'sessionPersistence') } @@ -123,6 +128,39 @@ export abstract class SessionPersistence extends Service { * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]> + + /** + * Atomically acquire this process's live ownership of a session id. + * Reentrant claims share one backend lease. First-party backends override + * this process-local fallback to reject another live process and reclaim a + * dead owner. + * @param id - session identity that is about to become live. + * @returns a single-release reference owned by the caller. + */ + claimLive(id: SessionId): Promise<SessionLiveLease> { + this.localLiveClaims.set(id, (this.localLiveClaims.get(id) ?? 0) + 1) + let released = false + return Promise.resolve({ + release: () => { + if (released) return Promise.resolve() + released = true + const refs = this.localLiveClaims.get(id) as number + if (refs <= 1) this.localLiveClaims.delete(id) + else this.localLiveClaims.set(id, refs - 1) + return Promise.resolve() + }, + }) + } + + /** + * Check whether any process currently owns a live lease for this session. + * The base implementation reports only claims on this service instance. + * @param id - persisted or prospective session identity. + * @returns true while a non-stale lease exists, including this process's lease. + */ + isLive(id: SessionId): Promise<boolean> { + return Promise.resolve(this.localLiveClaims.has(id)) + } } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts new file mode 100644 index 0000000000..5cc51117ab --- /dev/null +++ b/packages/session-persistence/session-persistence/src/lease.ts @@ -0,0 +1,98 @@ +/** Process-backed identity helpers for cross-process live-session leases. */ + +import { randomUUID } from 'node:crypto' + +const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' + +/** Process identity stored in backend-owned cross-process live-session leases. */ +export interface SessionLiveOwner { + /** Operating-system process id; retained across an `execve` handoff. */ + readonly pid: number + /** Per-process-start nonce that distinguishes PID reuse. */ + readonly nonce: string +} + +/** Idempotent capability releasing one acquired live-session lease reference. */ +export interface SessionLiveLease { + /** Release this caller's lease reference after its live session reaches quiescence. */ + release(): Promise<void> +} + +/** + * Stable owner inherited only by an exec-replaced process, not inferred from a session id. + * @returns this process's PID and exec-stable nonce. + */ +export function sessionLiveOwner(): SessionLiveOwner { + const nonce = process.env[LIVE_OWNER_ENV] ?? randomUUID() + process.env[LIVE_OWNER_ENV] = nonce + return { pid: process.pid, nonce } +} + +/** + * Whether a lease pid still names a process; permission denial counts as live. + * @param pid - positive operating-system process id from a lease record. + * @returns true unless the operating system reports that the process is absent. + */ +export function sessionLeaseProcessIsLive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +interface SharedLeaseEntry { + refs: number + readonly acquired: Promise<() => Promise<void>> +} + +const sharedLeases = new Map<string, SharedLeaseEntry>() + +/** + * Reference-count one physical lease across backend instances in this process. + * @param key - backend-kind plus canonical storage location and session id. + * @param acquire - single physical acquisition performed for the first reference. + * @returns an idempotent release for this caller's reference. + */ +export async function shareSessionLiveLease( + key: string, + acquire: () => Promise<() => Promise<void>>, +): Promise<() => Promise<void>> { + let entry = sharedLeases.get(key) + if (entry === undefined) { + entry = { refs: 0, acquired: acquire() } + sharedLeases.set(key, entry) + void entry.acquired.catch(() => { + /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + }) + } + entry.refs += 1 + try { + await entry.acquired + } catch (error) { + entry.refs -= 1 + throw error + } + let releaseTask: Promise<void> | undefined + return () => { + if (releaseTask !== undefined) return releaseTask + const task = (async () => { + entry.refs -= 1 + if (entry.refs > 0 || sharedLeases.get(key) !== entry) return + const release = await entry.acquired + await release() + /* v8 ignore next -- the entry remains installed until this exact final release succeeds */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + })() + const wrapped = task.catch((error: unknown) => { + entry.refs += 1 + /* v8 ignore next -- this closure is the sole writer of its releaseTask until settlement */ + if (releaseTask === wrapped) releaseTask = undefined + throw error + }) + releaseTask = wrapped + return wrapped + } +} diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts new file mode 100644 index 0000000000..e35bba9311 --- /dev/null +++ b/packages/session-persistence/session-persistence/tests/lease.spec.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { + sessionLeaseProcessIsLive, + sessionLiveOwner, + shareSessionLiveLease, +} from '../src/lease.ts' + +const originalOwner = process.env.DSH_SESSION_LIVE_OWNER + +afterEach(() => { + vi.restoreAllMocks() + if (originalOwner === undefined) delete process.env.DSH_SESSION_LIVE_OWNER + else process.env.DSH_SESSION_LIVE_OWNER = originalOwner +}) + +describe('process live-session lease helpers', () => { + it('creates one exec-stable owner identity and classifies process liveness', () => { + delete process.env.DSH_SESSION_LIVE_OWNER + const first = sessionLiveOwner() + expect(first.pid).toBe(process.pid) + expect(typeof first.nonce).toBe('string') + expect(sessionLiveOwner()).toEqual(first) + expect(sessionLeaseProcessIsLive(process.pid)).toBe(true) + + const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) + vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) + expect(sessionLeaseProcessIsLive(999_999)).toBe(false) + const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) + vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied }) + expect(sessionLeaseProcessIsLive(999_998)).toBe(true) + }) + + it('shares one physical lease until every process-local reference releases', async () => { + const releasePhysical = vi.fn<() => Promise<void>>(() => Promise.resolve()) + const acquire = vi.fn<() => Promise<() => Promise<void>>>(() => Promise.resolve(releasePhysical)) + const key = `shared-${randomUUID()}` + const first = await shareSessionLiveLease(key, acquire) + const second = await shareSessionLiveLease(key, acquire) + expect(acquire).toHaveBeenCalledTimes(1) + await first() + expect(releasePhysical).not.toHaveBeenCalled() + await second() + await second() + expect(releasePhysical).toHaveBeenCalledTimes(1) + }) + + it('removes failed acquisitions and retries a failed physical release', async () => { + const key = `retry-${randomUUID()}` + await expect(shareSessionLiveLease(key, () => Promise.reject(new Error('claim failed')))) + .rejects.toThrow('claim failed') + + let releases = 0 + const release = await shareSessionLiveLease(key, () => Promise.resolve(async () => { + releases += 1 + if (releases === 1) throw new Error('release failed') + })) + await expect(release()).rejects.toThrow('release failed') + await expect(release()).resolves.toBeUndefined() + expect(releases).toBe(2) + }) +}) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 6b31d0843b..36192c37d7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -4,7 +4,7 @@ import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionLiveOwner, type SessionPersistenceSnapshot, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -348,6 +348,46 @@ describe('PersistenceCoordinator stored identity', () => { }) }) +describe('PersistenceCoordinator live leases', () => { + it('degrades without backend hooks and retries a failed final release', async () => { + const fallbackCtx = new Context() + await fallbackCtx.plugin(SessionStore) + const fallback = new PersistenceCoordinator(fallbackCtx, new ControlledBackend()) + const fallbackClaim = await fallback.claimLive(SessionId('fallback-live')) + expect(await fallback.isLive(SessionId('fallback-live'))).toBe(false) + await fallbackClaim.release() + await fallbackCtx.fiber.dispose() + + class LeaseBackend extends ControlledBackend { + releaseAttempts = 0 + async acquireLive(_id: SessionId, _owner: SessionLiveOwner): Promise<() => Promise<void>> { + return async () => { + this.releaseAttempts += 1 + if (this.releaseAttempts === 1) throw new Error('lease release failed') + } + } + inspectLive(): Promise<boolean> { + return Promise.resolve(true) + } + } + + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new LeaseBackend() + const coordinator = new PersistenceCoordinator(ctx, backend) + const first = await coordinator.claimLive(SessionId('leased')) + const second = await coordinator.claimLive(SessionId('leased')) + expect(await coordinator.isLive(SessionId('leased'))).toBe(true) + await first.release() + await expect(second.release()).rejects.toThrow('lease release failed') + await expect(second.release()).resolves.toBeUndefined() + await expect(second.release()).resolves.toBeUndefined() + expect(backend.releaseAttempts).toBe(2) + expect(await coordinator.isLive(SessionId('leased'))).toBe(true) + await ctx.fiber.dispose() + }) +}) + describe('PersistenceCoordinator retirement', () => { it('a retiring unmaterialized owner without buffered events releases its id', async () => { const ctx = new Context() @@ -795,4 +835,20 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() } }) + + it('provides a reference-counted process-local lease fallback', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + const id = SessionId('local-live') + const first = await ctx.sessionPersistence.claimLive(id) + const second = await ctx.sessionPersistence.claimLive(id) + expect(await ctx.sessionPersistence.isLive(id)).toBe(true) + await first.release() + await first.release() + expect(await ctx.sessionPersistence.isLive(id)).toBe(true) + await second.release() + expect(await ctx.sessionPersistence.isLive(id)).toBe(false) + await ctx.fiber.dispose() + }) }) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index a83317ecf8..019eced081 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -5,6 +5,7 @@ ## Reads - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store. - `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title. diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 2028f908c1..826802e2b4 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -5,7 +5,7 @@ */ import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' +import { Session, type SessionId } from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { @@ -19,6 +19,7 @@ import type { SessionEventTraceRequest, SessionEventWindow, SessionLineageTrace, + SessionLogSnapshot, SessionRecord, SessionResultFilter, SessionSearchExecContext, @@ -118,6 +119,21 @@ export abstract class SessionQueryService extends Service { return this._corpus.listSessions() } + /** + * Read and replay-validate one complete logical session log without making it live. + * @param sessionId - live or persisted session id to read. + * @returns cloned header and complete raw event log from one observation. + * @throws when persistence, header compatibility, or replay validation fails. + */ + async readSession(sessionId: SessionId): Promise<SessionLogSnapshot> { + const loaded = await this._corpus.load(sessionId) + new Session(sessionId, loaded.events, loaded.header) + return { + session: structuredClone(loaded.header), + events: loaded.events.map(event => structuredClone(event)), + } + } + /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index b231bd9f78..0f89de8156 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -39,6 +39,14 @@ export interface SessionSurfaceSnapshot { events: SurfaceEvent[] } +/** One validated detached observation of a logical session's complete raw log. */ +export interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} + /** Lightweight metadata for one event within a logical session. */ export interface SessionEventRecord { /** Session that owns the event. */ diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index a2ea051dd0..a69c3bcb93 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -105,6 +105,25 @@ function rejectUnknown<T>(reason: unknown): Promise<T> { } describe('session-query exact reads', () => { + it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => { + const valid = header('valid-log', 2) + const corrupt = header('corrupt-log', 1) + const validEvents = eventLog('valid') + const corruptEvents = [{ ...eventLog('bad')[0]!, seq: 1 }] + TestPersistence.reset([ + { meta: valid, events: validEvents }, + { meta: corrupt, events: corruptEvents }, + ]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + const snapshot = await ctx.sessionQuery.readSession(valid.id) + expect(snapshot).toEqual({ session: valid, events: validEvents }) + Object.assign(snapshot.events[0]!, { time: 999 }) + expect(TestPersistence.entries.get(valid.id)?.events[0]?.time).toBe(10) + await expect(ctx.sessionQuery.readSession(corrupt.id)).rejects.toThrow('seed event at index 0 has seq 1') + }) + it('prefers a live owner that attaches while its persisted prefix is inspected', async () => { const shared = header('attach-during-inspect', 2) TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }]) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index abd8feec20..c37b2d5fb3 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -6,11 +6,12 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh | +| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index c303ac1e71..195f0bdb11 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -80,6 +80,18 @@ export function parseResumeArg( return { resumeSessionId, rest } } +/** + * Replace any existing resume flag with one canonical trailing `--resume <id>` pair. + * @param argv - current arguments after command dispatch. + * @param sessionId - selected session id. + * @returns flag-normalized arguments for a process replacement. + */ +export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] { + if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`) + const { rest } = parseResumeArg(argv) + return [...rest, RESUME_FLAG, sessionId] +} + /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the * ambient environment; other read failures are reported through `warn`. @@ -216,12 +228,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree * (see {@link loadPersonalPatches}); an empty list mounts none. + * @param prepare - optional host setup run against the root context before any Loader entry mounts. * @returns the root context once every entry has started. */ export async function boot( - binName: string, absoluteConfigPath: string, patches?: PatchOptions[], + binName: string, + absoluteConfigPath: string, + patches?: PatchOptions[], + prepare?: (ctx: Context) => Promise<void> | void, ): Promise<Context> { const ctx = new Context() + await prepare?.(ctx) ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 76e5238db7..6ea9c4e66e 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -55,6 +55,15 @@ describe('parseResumeArg', () => { }) }) +describe('replaceResumeArg', () => { + it('keeps positional arguments and replaces either existing flag form', () => { + expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(() => replaceResumeArg([], '')).toThrow('non-empty session id') + }) +}) + describe('loadEnv', () => { it('loads variables from .env in the given dir', () => { const dir = tmp() @@ -196,6 +205,19 @@ describe('boot', () => { } }) + it('runs host preparation before the Loader tree mounts', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const prepared: Context[] = [] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) }) + try { + expect(prepared).toEqual([ctx]) + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 21601fa280..ee3e787c3f 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,9 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output> `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place. +`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, flushes it, stops the terminal UI, and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. + +`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. ## Config @@ -42,17 +44,20 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | | `maxQuestionOptions` | `8` | Visible options in a question panel | | `maxModelOptions` | `8` | Visible models in the model selector | +| `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `resumeDialogWidth` | `88` | Resume-selector width in columns | +| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | -| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend | +| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id | ```yaml - id: terminal diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 4d668ba697..76e9c9c7aa 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -33,9 +33,11 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -48,6 +50,12 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, + "@deepseek-ai/dsh-session-query": { + "optional": true + }, + "@deepseek-ai/dsh-goal": { + "optional": true + }, "@deepseek-ai/dsh-skill": { "optional": true } @@ -60,6 +68,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index dfe420f1f6..7317f3000b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -66,12 +66,17 @@ import { type SessionHeader, type TodoItem, } from '@deepseek-ai/dsh-session' +import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { formatSessionReferenceMention, parseSessionReferenceText, type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { + SessionLogSnapshot, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' // Side-effect type import: declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' @@ -120,9 +125,22 @@ declare module 'cordis' { interface Context { /** Terminal-only interaction service, available only while a TUI is mounted. */ tui: TuiExtensionService + /** Optional process host that can replace this TUI with a resumed session. */ + tuiResumeHost: TuiResumeHost } } +/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ +export interface TuiResumeHost { + /** + * Dispose the current app and replace it with a runtime for `sessionId`. + * Success does not return. A host may reject before it commits teardown; + * after commit it owns fatal reporting and process exit. + * @param sessionId - validated persisted session selected by the user. + */ + handoff(sessionId: SessionId): Promise<never> +} + /** * Optional terminal-local interaction service provided by one mounted TUI. * @@ -162,7 +180,7 @@ export { } from './file-autocomplete.ts' export const name = 'ui-tui' -export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] +export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' @@ -177,6 +195,8 @@ export interface TuiConfig { maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -185,6 +205,10 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Resume-selector width in terminal columns. */ + resumeDialogWidth?: number + /** Resume-selector maximum height in terminal rows. */ + resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -210,10 +234,13 @@ const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const resumeDialogWidthSchema = z.number().step(1).min(36).default(88) +const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -228,10 +255,13 @@ const tuiConfigSchemaFields = { maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, + maxResumeOptions: maxResumeOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + resumeDialogWidth: resumeDialogWidthSchema, + resumeDialogMaxHeight: resumeDialogMaxHeightSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -251,11 +281,10 @@ export interface Config extends TuiConfig { /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** - * Shell command template shown for resuming this session: printed on exit and - * listed by `/resume`, with every `{session}` occurrence replaced by the live - * session id. Absent disables both surfaces. Deployments set it only when a - * persistence backend makes the session resumable (e.g. - * `RESUME_SESSION_ID={session} dsh`). + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. */ resumeCommand?: string } @@ -268,10 +297,13 @@ export const Config: z<Config> = z.object({ maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth, + resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -287,10 +319,13 @@ export interface ResolvedTuiConfig { maxToolOutputLines: number maxQuestionOptions: number maxModelOptions: number + maxResumeOptions: number questionDialogWidth: number questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + resumeDialogWidth: number + resumeDialogMaxHeight: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -314,6 +349,8 @@ export interface TuiRuntime { formatCwd?: (cwd: string | undefined) => string /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ now?(): number + /** Host-owned safe process handoff; absent leaves `resumeCommand` as the fallback. */ + handoffResume?: TuiResumeHost['handoff'] } /** @@ -328,10 +365,13 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf maxToolOutputLines: config?.maxToolOutputLines ?? 6, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, + maxResumeOptions: config?.maxResumeOptions ?? 8, questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 72, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + resumeDialogWidth: config?.resumeDialogWidth ?? 88, + resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], @@ -1236,6 +1276,173 @@ class ModelDialog implements Component { } } +interface ResumeRoute { + provider: string + model: string +} + +interface ResumeCandidate { + record: SessionRecord + occupied: boolean + title: string + lastActivityAt: number + lastTurn: string + route?: ResumeRoute + goalPhase?: GoalPhase + disabledReason?: string +} + +function resumeTurnLabel(snapshot: SessionLogSnapshot): string { + const event = snapshot.events.findLast(item => item.type === 'turn/end') + if (event === undefined) return 'no completed turn' + const reason = event.data.reason + switch (reason.kind) { + case 'completed': return `turn ${event.data.turn}: completed` + case 'aborted': return `turn ${event.data.turn}: cancelled` + case 'error': return `turn ${event.data.turn}: error` + case 'disposed': return `turn ${event.data.turn}: disposed` + case 'max-tokens': return `turn ${event.data.turn}: max tokens` + case 'rejected': return `turn ${event.data.turn}: rejected` + case 'interrupted': return `turn ${event.data.turn}: interrupted` + default: return `turn ${event.data.turn}: unknown result` + } +} + +function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { + const header = snapshot.events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } + : undefined +} + +function summarizeResumeCandidate( + record: SessionRecord, + snapshot: SessionLogSnapshot, + currentId: SessionId, + cwd: string | undefined, + occupied: boolean, + availableProviders: ReadonlySet<string>, +): ResumeCandidate { + const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' + const route = resumeRoute(snapshot) + const foldedGoal = foldGoal(snapshot.events).goal + let disabledReason: string | undefined + if (record.header.id === currentId) disabledReason = 'current session' + else if (record.live || occupied) disabledReason = 'occupied by another live agent' + else if (record.header.cwd !== cwd) disabledReason = 'different workspace' + else if (route !== undefined && !availableProviders.has(route.provider)) { + disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` + } + return { + record, + occupied, + title, + lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, + lastTurn: resumeTurnLabel(snapshot), + ...route === undefined ? {} : { route }, + ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, + ...disabledReason === undefined ? {} : { disabledReason }, + } +} + +/** Searchable keyboard selector over detached, preflighted resume summaries. */ +class ResumeDialog implements Component, Focusable { + private query = '' + private selectedIndex = 0 + private error = '' + focused = false + + constructor( + private readonly candidates: readonly ResumeCandidate[], + private readonly maxVisible: number, + private readonly palette: Palette, + private readonly done: (candidate: ResumeCandidate) => void, + private readonly cancel: () => void, + ) {} + + invalidate(): void {} + + private filtered(): ResumeCandidate[] { + const query = this.query.trim().toLocaleLowerCase() + if (query === '') return [...this.candidates] + return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query)) + } + + handleInput(data: string): void { + this.invalidate() + const filtered = this.filtered() + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + this.cancel() + return + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = filtered.length === 0 + ? 0 + : (this.selectedIndex + filtered.length - 1) % filtered.length + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.enter)) { + const selected = filtered[this.selectedIndex] + if (selected === undefined) this.error = 'No session matches this search.' + else if (selected.disabledReason !== undefined) this.error = selected.disabledReason + else this.done(selected) + } else if (data === '\x7f' || data === '\b') { + this.query = Array.from(this.query).slice(0, -1).join('') + this.selectedIndex = 0 + this.error = '' + } else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) { + this.query += data + this.selectedIndex = 0 + this.error = '' + } + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + const filtered = this.filtered() + if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + filtered.length - this.maxVisible, + )) + const end = Math.min(filtered.length, start + this.maxVisible) + const body: string[] = [ + this.query === '' + ? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}` + : this.palette.text(`Search: ${displayText(this.query)}`), + '', + ] + for (let index = start; index < end; index += 1) { + const candidate = filtered[index] as ResumeCandidate + const selected = index === this.selectedIndex + const status = [ + candidate.disabledReason === 'current session' ? 'current' : undefined, + candidate.record.live || candidate.occupied ? 'live' : undefined, + candidate.record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}` + body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead) + const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` + const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` + body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + if (candidate.disabledReason !== undefined) { + body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + } + } + if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.')) + if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`)) + body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel')) + if (this.error !== '') body.push(this.palette.error(displayText(this.error))) + return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette) + } +} + class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set<number>() @@ -1585,6 +1792,7 @@ export function createTuiChat( const agent = ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`) const persistence = ctx.get('sessionPersistence') + const sessionQuery = ctx.get('sessionQuery') const resolved = resolveTuiConfig(config) const palette = createPalette(resolved.color) const mdTheme = markdownTheme(palette) @@ -1631,6 +1839,9 @@ export function createTuiChat( const referenceControllers = new Set<AbortController>() let activeQuestion: PendingQuestion | undefined let modelOverlay: TuiOverlaySession | undefined + let resumeOverlay: TuiOverlaySession | undefined + let resumeInFlight = false + let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } let contextWindow: number | undefined @@ -1640,6 +1851,8 @@ export function createTuiChat( > | undefined let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() + const agentStatus = (): AgentStatus => agent.status + const isDisposed = (): boolean => disposed // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2204,7 +2417,6 @@ export function createTuiChat( } return all .filter(header => header.cwd === agent.session.header.cwd) - .sort((a, b) => b.createdAt - a.createdAt) } /** @@ -2597,37 +2809,153 @@ export function createTuiChat( }) } - /** - * List this workspace's resumable sessions, newest first, each with its - * resume command and a marker on the current one. Warns when resume is not - * configured or no persistence backend is mounted; notes when nothing is - * persisted yet. The listing is asynchronous (a persistence scan), so the - * transcript updates once it resolves. - */ - const showResume = (): void => { - const template = config.resumeCommand - if (template === undefined) { - appendNotice('Resume is not configured for this app.', 'warning') - return + /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + const readResumeCandidate = async ( + record: SessionRecord, + providers: ReadonlySet<string>, + ): Promise<ResumeCandidate> => { + try { + const occupied = record.live || (record.persisted && persistence !== undefined + ? await persistence.isLive(record.header.id) + : false) + let snapshot: SessionLogSnapshot + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) { + snapshot = { + session: structuredClone(live.header), + events: live.events.map(event => structuredClone(event)), + } + } else { + /* v8 ignore next -- caller checks the optional service before mapping records */ + if (sessionQuery === undefined) throw new Error('session query is unavailable') + snapshot = await sessionQuery.readSession(record.header.id) + } + return summarizeResumeCandidate( + record, + snapshot, + agent.session.id, + agent.session.header.cwd, + occupied, + providers, + ) + } catch (error: unknown) { + return { + record, + occupied: record.live, + title: 'Unreadable session', + lastActivityAt: record.header.createdAt, + lastTurn: 'log unavailable', + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + } } - if (persistence === undefined) { - appendNotice('Resume is not available: no persistence backend is mounted.', 'warning') - return - } - void listWorkspaceSessions().then((sessions) => { - if (sessions.length === 0) { - appendNotice('No resumable sessions found for this workspace yet.', 'info') + } + + /** Re-read every mutable precondition immediately before terminal handoff. */ + const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => { + /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ + if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + const initialStatus = agentStatus() + if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) + const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) + if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) + const candidate = await readResumeCandidate( + record, + new Set(ctx.llm.listProviders().map(provider => provider.id)), + ) + if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const finalStatus = agentStatus() + if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) + return candidate + } + + const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => { + if (resumeInFlight) return + resumeInFlight = true + try { + const checked = await preflightResume(candidate.record.header.id) + const hostHandoff = runtime.handoffResume + if (hostHandoff === undefined) { + const template = config.resumeCommand + const fallback = template?.replaceAll('{session}', checked.record.header.id) + await overlay.close() + resumeOverlay = undefined + appendNotice(fallback === undefined + ? 'Session is resumable, but this host cannot hand it off in place.' + : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.bold(palette.accent('Resumable sessions')), 1, 0)) - const lines = sessions.map((header) => { - const when = new Date(header.createdAt).toISOString().slice(0, 16).replace('T', ' ') - const marker = header.id === agent.session.id ? palette.success(' (current)') : '' - return `${palette.muted(when)}${marker}\n ${displayText(template.replaceAll('{session}', header.id))}` + await ctx.sessions.flush(agent.session) + if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) + await overlay.close() + resumeOverlay = undefined + await runtime.terminal.drainInput(100, 20) + ui.stop() + try { + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + /* v8 ignore next -- a committed host disposes this TUI and never returns; pre-commit rejection keeps it live */ + if (!disposed) { + ui.start() + ui.setFocus(editor) + appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + } + } + } catch (error: unknown) { + /* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */ + if (!disposed) { + await overlay.close() + resumeOverlay = undefined + appendNotice(`Resume failed: ${errorChain(error)}`, 'error') + } + } finally { + resumeInFlight = false + } + } + + /** Open the current-workspace searchable session selector. */ + const showResume = (): void => { + if (agent.status !== 'idle') { + appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') + return + } + if (sessionQuery === undefined) { + appendNotice('Resume is not available: session query is not mounted.', 'warning') + return + } + const scan = ++resumeScan + void resumeOverlay?.close() + void sessionQuery.listSessions().then(async (records) => { + if (isDisposed() || scan !== resumeScan) return + const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) + const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt + || a.record.header.id.localeCompare(b.record.header.id)) + if (isDisposed() || scan !== resumeScan) return + const session = overlayManager.open({ + create: () => new ResumeDialog( + candidates, + resolved.maxResumeOptions, + palette, + (candidate) => { void handoffResume(candidate, session) }, + () => { void session.close() }, + ), + options: { + width: resolved.resumeDialogWidth, + maxHeight: resolved.resumeDialogMaxHeight, + anchor: 'center', + margin: 1, + }, + }) + resumeOverlay = session + void session.closed.then(() => { + /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ + if (resumeOverlay === session) resumeOverlay = undefined }) - chat.addChild(new Text(lines.join('\n'), 1, 0)) requestRender() + }, (error: unknown) => { + if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') }) } @@ -2828,6 +3156,14 @@ export function createTuiChat( } rebuildTranscript(true) + const restoredGoal = foldGoal(agent.session.events).goal + if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') { + appendNotice( + `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. ` + + 'Human confirmation is required; send “继续” or run /goal resume.', + 'warning', + ) + } setStatus(agent.status) try { ui.start() @@ -2915,9 +3251,11 @@ export function apply(ctx: Context, config: Config): void { // Truecolor is a terminal capability, so detect it here at the process // boundary from COLORTERM; an explicit `truecolor` config value still wins. const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') + const resumeHost = ctx.get('tuiResumeHost') mountTui(ctx, Object.assign({}, config, { truecolor }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), + ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) }, }) } /* v8 ignore stop */ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c6da283236..6b9bd143d4 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -14,6 +14,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' +import { TestSessionQueryService } from './session-query.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -48,7 +49,14 @@ export interface TuiHarnessOptions { resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined> } /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */ - sessionPersistence?: { list(): Promise<SessionHeader[]> } + sessionPersistence?: { + list(): Promise<SessionHeader[]> + load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }> + isLive?(id: ReturnType<typeof SessionId>): Promise<boolean> + } + handoffResume?: TuiRuntime['handoffResume'] + /** Set false to exercise the optional session-query degradation path. */ + mountSessionQuery?: boolean } export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> { @@ -118,7 +126,26 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e } if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt) if (options.sessionPersistence !== undefined) { - ctx.provide('sessionPersistence', options.sessionPersistence as never) + const persistence = options.sessionPersistence + ctx.provide('sessionPersistence', { + ...persistence, + locate: () => undefined, + create: () => Promise.resolve(), + append: () => Promise.resolve(), + load: persistence.load === undefined + ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`)) + : (id: ReturnType<typeof SessionId>) => persistence.load!(id), + inspect: persistence.load === undefined + ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`)) + : (id: ReturnType<typeof SessionId>) => persistence.load!(id), + claimLive: () => Promise.resolve({ release: () => Promise.resolve() }), + isLive: persistence.isLive === undefined + ? () => Promise.resolve(false) + : (id: ReturnType<typeof SessionId>) => persistence.isLive!(id), + } as never) + } + if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) { + await ctx.plugin(TestSessionQueryService) } const sessionId = SessionId('main-session') const session = ctx.sessions.create( @@ -178,6 +205,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e // test pins the clock only by passing `now` explicitly. ...(options.now === undefined ? {} : { now: options.now }), ...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }), + ...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }), }) return { ctx, session, agent, terminal, exit, controller } } diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts index 149035099e..c5c497fab9 100644 --- a/packages/ui/tui/tests/plugin-shape.spec.ts +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -14,6 +14,7 @@ describe('dsh-tui plugin export shape', () => { expect(unwrapped.name).toBe('ui-tui') expect(unwrapped.inject).toEqual([ 'agents', + 'sessions', 'commands', 'userInteraction', 'tools', diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index 7711b71636..d78aa6d31f 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=10 bufferRow=10 +cursor hidden column=0 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -10,23 +10,60 @@ buffer style 1-21 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim -3| <blank> -4| " Resumable sessions " - style 1-18 fg=bright-blue bold -5| " 2024-01-02 03:04 (current) " - style 1-16 fg=bright-black - style 17-26 fg=green -6| " RESUME_SESSION_ID=main-session dsh " -7| " 2024-01-01 00:00 " - style 1-16 fg=bright-black -8| " RESUME_SESSION_ID=earlier-session dsh " -9| "────────────────────────────────────────────────────────────────────────────────────────────" +3| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -10| " " +4| " " style 1-1 inverse -11| "────────────────────────────────────────────────────────────────────────────────────────────" +5| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim style 65-91 dim -13-31| <blank> +7-8| <blank> +9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ " + style 2-89 fg=bright-blue +10| " │ Search: title or session id │ " + style 2-2 fg=bright-blue + style 4-10 fg=bright-black + style 12-30 dim + style 89-89 fg=bright-blue +11| " │ │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +12| " │ › Untitled session │ " + style 2-2 fg=bright-blue + style 4-21 fg=bright-blue bold + style 89-89 fg=bright-blue +13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ " + style 2-2 fg=bright-blue + style 4-69 fg=bright-black + style 89-89 fg=bright-blue +14| " │ current · live · main-session │ " + style 2-2 fg=bright-blue + style 4-34 dim + style 89-89 fg=bright-blue +15| " │ unavailable: current session │ " + style 2-2 fg=bright-blue + style 4-33 fg=yellow + style 89-89 fg=bright-blue +16| " │ Resume selector design │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ " + style 2-2 fg=bright-blue + style 4-76 fg=bright-black + style 89-89 fg=bright-blue +18| " │ persisted · earlier-session │ " + style 2-2 fg=bright-blue + style 4-32 dim + style 89-89 fg=bright-blue +19| " │ │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ " + style 2-2 fg=bright-blue + style 4-60 dim + style 89-89 fg=bright-blue +21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " + style 2-89 fg=bright-blue +22-31| <blank> diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 182f9aae14..16d6794002 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -646,13 +646,27 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) - it('lists this workspace\'s resumable sessions with their commands', async () => { + it('opens the searchable resume selector with log-backed session summaries', async () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z')) + const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' } const harness = await setupSnapshot({ config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' }, - sessionPersistence: { list: async () => [ - { version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' }, - { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }, - ] }, + sessionPersistence: { + list: async () => [earlier], + load: async () => ({ + meta: earlier, + events: [ + { type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } }, + ], + }), + }, }, { columns: 92, rows: 32 }) harness.terminal.send('/resume') harness.terminal.send('\r') @@ -662,6 +676,7 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.flush() await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) + dateNow.mockRestore() }) it('pins the detailed session diagnostics card', async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 724ef9697c..a6f8d30341 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -6,8 +6,10 @@ import { Context } from 'cordis' import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' -import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -152,10 +154,13 @@ describe('TUI config', () => { maxToolOutputLines: 6, maxQuestionOptions: 8, maxModelOptions: 8, + maxResumeOptions: 8, questionDialogWidth: 200, questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, + resumeDialogWidth: 88, + resumeDialogMaxHeight: 24, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -169,10 +174,13 @@ describe('TUI config', () => { maxToolOutputLines: 2, maxQuestionOptions: 3, maxModelOptions: 4, + maxResumeOptions: 5, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + resumeDialogWidth: 84, + resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -185,10 +193,13 @@ describe('TUI config', () => { maxToolOutputLines: 2, maxQuestionOptions: 3, maxModelOptions: 4, + maxResumeOptions: 5, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + resumeDialogWidth: 84, + resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -204,6 +215,21 @@ describe('resume command and /resume', () => { const RESUME = 'RESUME_SESSION_ID={session} dsh' const header = (id: string, createdAt: number, cwd: string): SessionHeader => ({ version: 0, id: SessionId(id), createdAt, cwd }) + const resumeEvents = ( + title: string, + provider = 'deepseek', + time = 100, + reason: TurnEndReason = { kind: 'completed' }, + ): SessionEvent[] => [ + { type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: time + 1, data: { content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: time + 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'done' }], provenance: { provider, model: 'model-1' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, + { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, + ] it('prints the resume command on exit once the session is persisted', async () => { const result = await setup({ @@ -243,73 +269,589 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('lists this workspace\'s sessions newest-first and marks the current one', async () => { + it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => { + const older = header('older-session', 500, '/workspace') + const newer = header('newer-session', 2000, '/workspace') + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME }, + handoffResume: handoff, sessionPersistence: { - list: async () => [ - header('main-session', 1000, '/workspace'), - header('older-session', 500, '/workspace'), - header('newer-session', 2000, '/workspace'), - header('foreign-session', 3000, '/elsewhere'), - ], + list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')], + load: async id => id === newer.id + ? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) } + : { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + const output = result.terminal.output + expect(output).toContain('Resume session') + expect(output).toContain('Newer product work') + expect(output).toContain('Older investigation') + expect(output).toContain('current · live') + expect(output.indexOf('Newer product work')).toBeLessThan(output.indexOf('Older investigation')) + expect(output).not.toContain('foreign-session') + result.terminal.send('Older') + await tick() + expect(result.terminal.output).toContain('Search: Older') + result.terminal.send('\x1b') + await tick() + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + + it('handles selector navigation, empty matches, and backspace search edits', async () => { + const target = header('keyboard-target', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Keyboard target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + result.terminal.send('zz') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('No session matches this search') + result.terminal.send('\x7f') + result.terminal.send('\x7f') + await tick() + expect(result.terminal.output).toContain('Search: title or session id') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('current session') + result.terminal.send('\x1b') + await dispose(result) + }) + + it('clips candidate count through the configured visible-session limit', async () => { + const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')] + const result = await setup({ + cwd: '/workspace', + config: { maxResumeOptions: 1 }, + sessionPersistence: { + list: async () => targets, + load: async id => ({ + meta: targets.find(target => target.id === id)!, + events: resumeEvents(`Limited ${id}`), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('1/3') + await dispose(result) + }) + + it.each([ + [{ kind: 'aborted' }, 'cancelled'], + [{ kind: 'error', step: 1, message: 'failed' }, 'error'], + [{ kind: 'disposed' }, 'disposed'], + [{ kind: 'max-tokens' }, 'max tokens'], + [{ kind: 'rejected', reason: 'policy' }, 'rejected'], + [{ kind: 'interrupted' }, 'interrupted'], + [{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'], + ] as const)('renders the last turn result %s', async (reason, label) => { + const target = header(`turn-${label}`, 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain(`turn 1: ${label}`) + await dispose(result) + }) + + it('refuses while running instead of cancelling or switching', async () => { + const result = await setup({ cwd: '/workspace', status: 'running' }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('finish or be cancelled first') + expect(result.agent.cancelled).toEqual([]) + await dispose(result) + }) + + it('warns when the optional session-query service is absent', async () => { + const result = await setup({ cwd: '/workspace', mountSessionQuery: false }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('session query is not mounted') + await dispose(result) + }) + + it('keeps persisted query records readable when live-lease inspection is unavailable', async () => { + const target = header('query-only-persisted', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: false, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Query-only persisted session'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Query-only persisted session') + expect(result.terminal.output).toContain('persisted') + expect(result.terminal.output).not.toContain('session cannot be loaded') + await dispose(result) + }) + + it('contains a session-query scan failure in the current TUI', async () => { + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.reject(new Error('index unavailable')), + } as never) }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick() - const output = result.terminal.output - expect(output).toContain('Resumable sessions') - expect(output).toContain('RESUME_SESSION_ID=main-session dsh') - expect(output).toContain('(current)') - expect(output).toContain('RESUME_SESSION_ID=newer-session dsh') - expect(output).not.toContain('foreign-session') - // Newest-first: the newer session's command precedes the current session's. - // Match the full resume command, not the bare id: the banner detail line - // echoes the current session id (`main-session`) above the listing. - expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan( - output.indexOf('RESUME_SESSION_ID=main-session'), - ) - expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan( - output.indexOf('RESUME_SESSION_ID=older-session'), - ) + expect(result.terminal.output).toContain('Resume session scan failed: index unavailable') + expect(result.terminal.stopped).toBe(0) await dispose(result) }) - it('warns from /resume when resume is not configured', async () => { - const result = await setup({ cwd: '/workspace' }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Resume is not configured') - await dispose(result) - }) - - it('warns from /resume when no persistence backend is mounted', async () => { - const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('no persistence backend is mounted') - await dispose(result) - }) - - it('notes from /resume when no workspace sessions are persisted yet', async () => { + it('supersedes a slower prior selector scan', async () => { + const first = Promise.withResolvers<SessionRecord[]>() + let calls = 0 const result = await setup({ - cwd: '/workspace', - config: { resumeCommand: RESUME }, - sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] }, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + first.reject(new Error('superseded scan failed')) + await tick() + expect(calls).toBe(2) + expect(result.terminal.output).toContain('No matching sessions') + expect(result.terminal.output).not.toContain('superseded scan failed') + result.terminal.send('\x1b[A') + result.terminal.send('\x1b[B') + await dispose(result) + }) + + it('drops a selector scan that resolves after TUI disposal', async () => { + const listing = Promise.withResolvers<SessionRecord[]>() + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { listSessions: () => listing.promise } as never) + }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('No resumable sessions found') + await dispose(result) + listing.resolve([]) + await tick() + expect(result.terminal.stopped).toBeGreaterThan(0) + }) + + it('drops loaded selector summaries when the TUI disposed during log reads', async () => { + const target = header('dispose-during-load', 10, '/workspace') + const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: () => loading.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + await dispose(result) + loading.resolve({ meta: target, events: resumeEvents('Disposed load') }) + await tick() + expect(result.terminal.stopped).toBeGreaterThan(0) + }) + + it('preflights route availability and occupied or corrupt sessions without losing the current TUI', async () => { + const missing = header('missing-route', 10, '/workspace') + const occupied = header('occupied', 20, '/workspace') + const corrupt = header('corrupt', 30, '/workspace') + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { + list: async () => [missing, occupied, corrupt], + isLive: async id => id === occupied.id, + load: async (id) => { + if (id === corrupt.id) throw new Error('checksum mismatch') + return { + meta: id === missing.id ? missing : occupied, + events: resumeEvents(id === missing.id ? 'Missing adapter' : 'Busy session', id === missing.id ? 'absent-provider' : 'deepseek'), + } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Missing adapter') + expect(result.terminal.output).toContain('absent-provider/model-1') + expect(result.terminal.output).toContain('Busy session') + expect(result.terminal.output).toContain('Unreadable session') + result.terminal.send('Missing adapter') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('route is currently unavailable') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('falls back to assistant provenance and header creation time for sparse logs', async () => { + const assistantOnly = header('assistant-route', 20, '/workspace') + const empty = header('empty-log', 10, '/workspace') + const events = resumeEvents('Assistant route', 'deepseek') + .filter(event => event.type !== 'request/header') + .map((event, seq) => ({ ...event, seq })) as SessionEvent[] + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [assistantOnly, empty], + load: async id => id === assistantOnly.id + ? { meta: assistantOnly, events } + : { meta: empty, events: [] }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('deepseek/model-1') + expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString()) + await dispose(result) + }) + + it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { + const target = header('target-session', 10, '/workspace') + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process'))) + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Target session') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Target session') + result.terminal.send('\r') + await tick(); await tick() + expect(handoff).toHaveBeenCalledTimes(1) + expect(handoff).toHaveBeenCalledWith(target.id) + expect(result.terminal.stopped).toBeGreaterThan(0) + expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') + await dispose(result) + }) + + it('restores the UI when a host returns instead of replacing the process', async () => { + const target = header('returning-host', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + handoffResume: async () => undefined as never, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Returning host') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Returning host') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('resume host returned without replacing the process') + await dispose(result) + }) + + it('keeps the current TUI when the selected log fails its second preflight load', async () => { + const target = header('racing-corruption', 10, '/workspace') + let loads = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => { + if (++loads > 1) throw new Error('log changed during selection') + return { meta: target, events: resumeEvents('Racing corruption') } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Racing corruption') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume failed: session cannot be loaded: failed to inspect session') + expect(result.terminal.output).toContain('log changed during selection') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('rejects a candidate whose cwd changes between listing and preflight', async () => { + const target = header('moving-workspace', 10, '/workspace') + let listings = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [++listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere')], + load: async () => ({ + meta: listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere'), + events: resumeEvents('Moving workspace'), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Moving workspace') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('different workspace') + await dispose(result) + }) + + it('admits only one handoff while the selected preflight is pending', async () => { + const target = header('single-handoff', 10, '/workspace') + const preflight = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + let loads = 0 + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: () => ++loads === 1 + ? Promise.resolve({ meta: target, events: resumeEvents('Single handoff') }) + : preflight.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Single handoff') + result.terminal.send('\r') + result.terminal.send('\r') + await tick() + preflight.resolve({ meta: target, events: resumeEvents('Single handoff') }) + await tick(); await tick() + expect(loads).toBe(2) + await dispose(result) + }) + + it('rechecks running state and candidate existence before loading the selected log', async () => { + const target = header('preflight-races', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Preflight races') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.agent.status = 'running' + result.terminal.send('Preflight races') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + result.agent.status = 'idle' + await dispose(result) + + let disappearingLists = 0 + const disappearing = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => ++disappearingLists <= 2 ? [target] : [], + load: async () => ({ meta: target, events: resumeEvents('Disappearing target') }), + }, + }) + disappearing.terminal.send('/resume') + disappearing.terminal.send('\r') + await tick(); await tick() + disappearing.terminal.send('Disappearing target') + disappearing.terminal.send('\r') + await tick() + expect(disappearing.terminal.output).toContain('is no longer available') + await dispose(disappearing) + }) + + it('rechecks idleness after the selected log finishes loading', async () => { + const target = header('load-turns-running', 10, '/workspace') + let loads = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => { + loads += 1 + if (loads === 2) result.agent.status = 'running' + return { meta: target, events: resumeEvents('Load turns running') } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Load turns running') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + result.agent.status = 'idle' + await dispose(result) + }) + + it('keeps resumeCommand as a displayed fallback when the host cannot hand off', async () => { + const target = header('fallback-session', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Fallback target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Fallback target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:') + expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('keeps the selector independent from an absent command fallback', async () => { + const target = header('no-fallback-session', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('No fallback target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('No fallback target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place') + await dispose(result) + }) + + it('rechecks idleness after the current-session flush', async () => { + const target = header('post-flush-running', 10, '/workspace') + const control: { setRunning?: () => void } = {} + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', () => { control.setRunning?.() }) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Post-flush running') }), + }, + }) + control.setRunning = () => { result.agent.status = 'running' } + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Post-flush running') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + expect(handoff).not.toHaveBeenCalled() + result.agent.status = 'idle' await dispose(result) }) }) describe('pi-tui chat lifecycle and transcript', () => { + it('restores durable goal phase without implying automatic continuation', async () => { + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: 'create', + goal: { + id: GoalId('restored-goal'), + revision: 1, + objective: 'Resume only with human confirmation', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 10, + updatedAt: 10, + } + const result = await setup({ + beforeMount(session) { + session.append('context/message', { + content: renderGoalChange(change), + source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, + meta: change as unknown as JsonValue, + }, { surfaceOp: 'append' }) + }, + }) + expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') + expect(result.terminal.output).toContain('/goal resume') + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('goal active') + await dispose(result) + }) + it('uses the latest log-backed title for the header subtitle and terminal window', async () => { const result = await setup({ // A fixed short cwd keeps the footer's token counters inside the 88-column diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index cf0a2b544f..3560d6bc9d 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/agent-loop" }, + { + "path": "../../goal/goal" + }, { "path": "../../core/session" }, @@ -29,6 +32,9 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../session-query/session-query" + }, { "path": "../../session-title/session-title" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..21eb750454 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-tui': + specifier: workspace:^ + version: link:../../packages/ui/tui + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) apps/web: dependencies: @@ -3836,6 +3842,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8318d59996..6be2d7ed83 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -52,6 +52,7 @@ export const LINK_MAP: Record<string, string> = { SessionEvent: 'core.md', SessionId: 'core.md', SessionStartSource: 'core.md', + SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', ApprovalPolicy: 'approval.md', @@ -94,6 +95,7 @@ export const LINK_MAP: Record<string, string> = { CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', + SessionLiveLease: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 89a0778827..1472499819 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -379,6 +379,11 @@ "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionLiveLease", + "source": "packages/session-persistence/session-persistence/src/lease.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", @@ -389,6 +394,11 @@ "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionLogSnapshot", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSurfaceSnapshot", From 54d986ed87a022643d1de9b890712635942882bc Mon Sep 17 00:00:00 2001 From: NI0317 <stniii317@gmail.com> Date: Fri, 24 Jul 2026 12:58:53 +0800 Subject: [PATCH 094/113] fix(tui): close resume handoff races --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 4 +- .../2026-07-21-tui-resume-command.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/module-graph.md | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 10 +- .../tests/jsonl.spec.ts | 7 + .../session-persistence-sqlite/README.md | 1 + .../session-persistence-sqlite/src/index.ts | 6 +- .../tests/sqlite.spec.ts | 18 +- .../session-persistence/README.md | 1 + .../session-persistence/src/index.ts | 7 +- .../session-persistence/src/lease.ts | 93 +++++---- .../session-persistence/tests/lease.spec.ts | 30 +++ packages/ui/tui/README.md | 2 +- packages/ui/tui/package.json | 3 - packages/ui/tui/src/index.ts | 65 +++++-- packages/ui/tui/tests/harness.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 182 ++++++++++++++++++ 20 files changed, 375 insertions(+), 76 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 42370c5dad..c04f198e4a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 23755696a9b7b379f0341c472769684839b37211 -2026-07-21-tui-resume-command.zh.md: cd2e19a2ef95409e8e11199f08afa996e8b07414 +2026-07-21-tui-resume-command.md: cd08b56f1e887473fd1df9f5f6055cb7c5e0a9b4 +2026-07-21-tui-resume-command.zh.md: ef641292dd178e19f94b6f79d3ab8607da27ee6d diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 23755696a9..cd08b56f1e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -14,9 +14,9 @@ The original `/resume` printed shell commands. It did not let a keyboard user in `session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly. -First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID, and release only after the exact session lifecycle drains. `AgentLoop.resume()` claims before load, closing the preflight/start race. +First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID or a same-PID different-incarnation owner, and release only after the exact session lifecycle drains. A final process-local release excludes reacquisition until the physical lease settles. `AgentLoop.resume()` claims before load, closing the preflight/start race. -After preflight, the TUI flushes the current session and stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. +After preflight, the TUI claims the target's exec-stable live lease before flushing the current session. A lost claim race remains in the current TUI; any later recoverable failure releases the reservation. The TUI then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process while retaining the target reservation rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index cd2e19a2ef..ef641292dd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -14,9 +14,9 @@ Status: implemented `session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 -第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 的租约,并且仅在对应会话生命周期完全停稳后释放租约。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 +第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 或 PID 相同但进程代际不同的租约,并且仅在对应会话生命周期完全停稳后释放租约。进程内最后一个引用开始释放后,新的领取操作必须等待物理租约完成释放再重新获取。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 -预检通过后,TUI 先刷写当前会话并停止终端,再调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会先领取目标会话在进程替换前后保持稳定的活跃租约,再刷写当前会话。如果目标在预检后被其他进程抢占,当前 TUI 会继续运行;之后任何可恢复失败也会释放该预留租约。随后 TUI 停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,同时保留目标预留租约,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 `resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5b3f7c4afb..f0ee05d705 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -983,7 +983,7 @@ isLive(id: SessionId): Promise<boolean> Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:55`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/module-graph.md b/docs/module-graph.md index 7e2ee76934..212fd723a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -674,11 +674,13 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_goal pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_session_persistence + pkg_tui --> pkg_session_query pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title pkg_tui --> pkg_skill @@ -898,7 +900,7 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 4aaf30f45a..86a8c0f1d6 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -69,6 +69,6 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **Lease scope is local-host advisory ownership** — PID liveness prevents two ordinary local Harness processes from resuming the same id, but it is not a distributed lease for shared network filesystems or hostile principals. +- **Lease scope is local-host advisory ownership** — PID plus same-process nonce checks prevent two ordinary local Harness processes from resuming the same id, but foreign PID reuse remains fail-closed and this is not a distributed lease for shared network filesystems or hostile principals. - **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a3ed611a4c..8d96a17681 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -314,7 +314,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error const current = await this.readLiveLease(path) if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break - if (current === undefined || sessionLeaseProcessIsLive(current.pid)) { + if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } const reclaimPath = `${path}.reclaim` @@ -335,7 +335,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (latest === undefined) { if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`) } else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) { - if (sessionLeaseProcessIsLive(latest.pid)) { + if (sessionLeaseOwnerIsLive(latest, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } await rm(path, { force: true }) @@ -356,13 +356,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** Report one non-stale process lease and clean up a crashed owner's record. */ + /** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */ async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> { const path = this.liveLeasePath(id) const current = await this.readLiveLease(path) if (current === undefined) return await this.exists(path) if (current.pid === owner.pid && current.nonce === owner.nonce) return true - if (sessionLeaseProcessIsLive(current.pid)) return true + if (sessionLeaseOwnerIsLive(current, owner)) return true return false } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 517cf7bb04..269d56f886 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -249,6 +249,13 @@ describe('SessionPersistenceJsonl: cross-process live leases', () => { const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited) await inheritedClaim.release() + const reusedPid = SessionId('reused-pid') + const reusedPidPath = join(liveDir, `${encodeSegment(reusedPid)}.lock`) + await writeFile(reusedPidPath, JSON.stringify({ pid: process.pid, nonce: 'prior-incarnation' })) + await expect(ctx.sessionPersistence.isLive(reusedPid)).resolves.toBe(false) + const reusedPidClaim = await ctx.sessionPersistence.claimLive(reusedPid) + await reusedPidClaim.release() + await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300)))) .rejects.toThrow() diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 374da93faa..42421b2e81 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -57,3 +57,4 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. - **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). +- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4399ee9c90..091880e87e 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,7 +15,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -295,7 +295,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current !== undefined && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { - if (sessionLeaseProcessIsLive(current.pid)) { + if (sessionLeaseOwnerIsLive(current, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id) @@ -323,7 +323,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current === undefined) return false if ((current.pid === owner.pid && current.nonce === owner.nonce) - || sessionLeaseProcessIsLive(current.pid)) return true + || sessionLeaseOwnerIsLive(current, owner)) return true this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?') .run(id, current.pid, current.nonce) return false diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a3aba22323..b670520a5c 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -13,7 +13,10 @@ import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../sessi import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] -afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +afterEach(async () => { + vi.restoreAllMocks() + for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) +}) async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> { try { @@ -465,9 +468,16 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b.ctx.sessionPersistence.list() const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite const owner = sessionLiveOwner() + const occupiedPid = process.pid + 1 + const originalKill = process.kill.bind(process) + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === occupiedPid) return true + return originalKill(pid, signal) + }) const db = openDatabase(path, 'wal') const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') - insert.run('occupied-lease', process.pid, 'another-owner') + insert.run('occupied-lease', occupiedPid, 'another-owner') + insert.run('reused-pid', process.pid, 'prior-incarnation') insert.run('stale-claim', 2_147_483_647, 'dead-owner') insert.run('stale-inspect', 2_147_483_647, 'dead-owner') insert.run('owned-inspect', owner.pid, owner.nonce) @@ -475,11 +485,13 @@ describe('SessionPersistenceSqlite: edge cases', () => { await expect(concrete.acquireLive(SessionId('occupied-lease'), owner)) .rejects.toThrow('occupied by another live process') + const reused = await concrete.acquireLive(SessionId('reused-pid'), owner) const claim = await concrete.acquireLive(SessionId('stale-claim'), owner) expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true) expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false) expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false) await claim() + await reused() await b.dispose() const memory = new Context() diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 2bfa3a31b1..0aec3a4bd5 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -85,3 +85,4 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov - **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance. - **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale. - **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it. +- **Foreign PID reuse is fail-closed** — a claimant with the reused PID detects its different nonce and reclaims safely, but another process cannot observe that foreign process's private nonce and treats the PID as live until it exits or an operator verifies and removes the stale lease. diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 3aa602c8c0..d6bbee0616 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -13,7 +13,12 @@ import type { SessionLiveLease } from './lease.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' -export { sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease } from './lease.ts' +export { + sessionLeaseOwnerIsLive, + sessionLeaseProcessIsLive, + sessionLiveOwner, + shareSessionLiveLease, +} from './lease.ts' export type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** Lightweight immutable source identity returned without loading a full log. */ diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts index 5cc51117ab..148d8e117f 100644 --- a/packages/session-persistence/session-persistence/src/lease.ts +++ b/packages/session-persistence/session-persistence/src/lease.ts @@ -8,7 +8,7 @@ const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' export interface SessionLiveOwner { /** Operating-system process id; retained across an `execve` handoff. */ readonly pid: number - /** Per-process-start nonce that distinguishes PID reuse. */ + /** Exec-stable process-start nonce used when the observer has the same PID. */ readonly nonce: string } @@ -42,9 +42,26 @@ export function sessionLeaseProcessIsLive(pid: number): boolean { } } +/** + * Whether a recorded owner still names this process incarnation or another live PID. + * A same-PID nonce mismatch proves reuse and is stale; an unrelated live PID is + * fail-closed because its private nonce is not observable across processes. + * @param recorded - owner stored in the backend lease. + * @param observer - identity of the process inspecting or claiming the lease. + * @returns whether the recorded owner must still be treated as live. + */ +export function sessionLeaseOwnerIsLive( + recorded: SessionLiveOwner, + observer: SessionLiveOwner, +): boolean { + if (recorded.pid === observer.pid) return recorded.nonce === observer.nonce + return sessionLeaseProcessIsLive(recorded.pid) +} + interface SharedLeaseEntry { refs: number readonly acquired: Promise<() => Promise<void>> + finalizing?: Promise<void> } const sharedLeases = new Map<string, SharedLeaseEntry>() @@ -59,40 +76,48 @@ export async function shareSessionLiveLease( key: string, acquire: () => Promise<() => Promise<void>>, ): Promise<() => Promise<void>> { - let entry = sharedLeases.get(key) - if (entry === undefined) { - entry = { refs: 0, acquired: acquire() } - sharedLeases.set(key, entry) - void entry.acquired.catch(() => { - /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - }) - } - entry.refs += 1 - try { - await entry.acquired - } catch (error) { - entry.refs -= 1 - throw error - } - let releaseTask: Promise<void> | undefined - return () => { - if (releaseTask !== undefined) return releaseTask - const task = (async () => { + for (;;) { + let entry = sharedLeases.get(key) + if (entry?.finalizing !== undefined) { + await entry.finalizing + continue + } + if (entry === undefined) { + entry = { refs: 0, acquired: acquire() } + sharedLeases.set(key, entry) + void entry.acquired.catch(() => { + /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + }) + } + entry.refs += 1 + try { + await entry.acquired + } catch (error) { entry.refs -= 1 - if (entry.refs > 0 || sharedLeases.get(key) !== entry) return - const release = await entry.acquired - await release() - /* v8 ignore next -- the entry remains installed until this exact final release succeeds */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - })() - const wrapped = task.catch((error: unknown) => { - entry.refs += 1 - /* v8 ignore next -- this closure is the sole writer of its releaseTask until settlement */ - if (releaseTask === wrapped) releaseTask = undefined throw error - }) - releaseTask = wrapped - return wrapped + } + let releaseTask: Promise<void> | undefined + return () => { + if (releaseTask !== undefined) return releaseTask + const task = (async () => { + entry.refs -= 1 + if (entry.refs > 0 || sharedLeases.get(key) !== entry) return + const release = await entry.acquired + await release() + /* v8 ignore next -- claims wait for finalization before they can replace this exact entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + })() + const wrapped = task.catch((error: unknown) => { + entry.refs += 1 + /* v8 ignore next -- this closure is the sole writer of its release state until settlement */ + if (entry.finalizing === wrapped) delete entry.finalizing + releaseTask = undefined + throw error + }) + if (entry.refs === 0 && sharedLeases.get(key) === entry) entry.finalizing = wrapped + releaseTask = wrapped + return wrapped + } } } diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts index e35bba9311..8c38aac874 100644 --- a/packages/session-persistence/session-persistence/tests/lease.spec.ts +++ b/packages/session-persistence/session-persistence/tests/lease.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { randomUUID } from 'node:crypto' import { + sessionLeaseOwnerIsLive, sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease, @@ -21,10 +22,14 @@ describe('process live-session lease helpers', () => { expect(first.pid).toBe(process.pid) expect(typeof first.nonce).toBe('string') expect(sessionLiveOwner()).toEqual(first) + expect(sessionLeaseOwnerIsLive(first, first)).toBe(true) + expect(sessionLeaseOwnerIsLive({ ...first, nonce: 'reused-pid' }, first)).toBe(false) expect(sessionLeaseProcessIsLive(process.pid)).toBe(true) const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) + expect(sessionLeaseOwnerIsLive({ pid: 999_999, nonce: 'gone' }, first)).toBe(false) + vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) expect(sessionLeaseProcessIsLive(999_999)).toBe(false) const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied }) @@ -59,4 +64,29 @@ describe('process live-session lease helpers', () => { await expect(release()).resolves.toBeUndefined() expect(releases).toBe(2) }) + + it('waits for a final physical release before reacquiring the same key', async () => { + const key = `finalizing-${randomUUID()}` + const releaseGate = Promise.withResolvers<undefined>() + const firstPhysicalRelease = vi.fn(() => releaseGate.promise) + const secondPhysicalRelease = vi.fn(() => Promise.resolve()) + const releases: Array<() => Promise<void>> = [firstPhysicalRelease, secondPhysicalRelease] + let acquisitions = 0 + const acquire = vi.fn<() => Promise<() => Promise<void>>>((): Promise<() => Promise<void>> => { + const release = releases[acquisitions++] + if (release === undefined) throw new Error('unexpected physical acquisition') + return Promise.resolve(release) + }) + const first = await shareSessionLiveLease(key, acquire) + const finalizing = first() + const reacquiring = shareSessionLiveLease(key, acquire) + await Promise.resolve() + expect(acquire).toHaveBeenCalledTimes(1) + releaseGate.resolve(undefined) + await finalizing + const second = await reacquiring + expect(acquire).toHaveBeenCalledTimes(2) + await second() + expect(secondPhysicalRelease).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index ee3e787c3f..db13d8733e 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output> `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, flushes it, stops the terminal UI, and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, and claims the target live lease before flushing the current session; a lost claim race or later recoverable failure leaves the current TUI running and releases any acquired reservation. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process while retaining the reservation, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 76e9c9c7aa..3242f5a2a3 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -53,9 +53,6 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, - "@deepseek-ai/dsh-goal": { - "optional": true - }, "@deepseek-ai/dsh-skill": { "optional": true } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 7317f3000b..90297fc478 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -77,9 +77,9 @@ import type { SessionLogSnapshot, SessionRecord, } from '@deepseek-ai/dsh-session-query' -// Side-effect type import: declaration-merges the optional `sessionPersistence` +// Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. -import type {} from '@deepseek-ai/dsh-session-persistence' +import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, @@ -1841,6 +1841,8 @@ export function createTuiChat( let modelOverlay: TuiOverlaySession | undefined let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false + let resumeReservation: SessionLiveLease | undefined + let resumeReservationCommitted = false let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } @@ -1853,6 +1855,12 @@ export function createTuiChat( const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed + const releaseResumeReservation = async (): Promise<void> => { + const reservation = resumeReservation + if (reservation === undefined) return + await reservation.release() + resumeReservation = undefined + } // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2436,6 +2444,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() + /* v8 ignore else -- the committed branch is the non-returning exec handoff covered by the keyless PTY test */ + if (!resumeReservationCommitted) await releaseResumeReservation() contextResolution = undefined clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) @@ -2871,6 +2881,7 @@ export function createTuiChat( const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => { if (resumeInFlight) return resumeInFlight = true + let terminalReleased = false try { const checked = await preflightResume(candidate.record.header.id) const hostHandoff = runtime.handoffResume @@ -2884,30 +2895,52 @@ export function createTuiChat( : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } + if (persistence === undefined) { + throw new Error('Resume is unavailable: session persistence is not mounted.') + } + resumeReservation = await persistence.claimLive(checked.record.header.id) + if (disposed) { + await releaseResumeReservation() + return + } await ctx.sessions.flush(agent.session) + // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (disposed) return if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) await overlay.close() resumeOverlay = undefined await runtime.terminal.drainInput(100, 20) + // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (disposed) return ui.stop() - try { - await hostHandoff(checked.record.header.id) - throw new Error('resume host returned without replacing the process') - } catch (error: unknown) { - /* v8 ignore next -- a committed host disposes this TUI and never returns; pre-commit rejection keeps it live */ - if (!disposed) { + terminalReleased = true + resumeReservationCommitted = true + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + /* v8 ignore next -- a committed host disposes this TUI and never returns; recoverable rejection keeps it live */ + if (!disposed) { + resumeReservationCommitted = false + let reported = error + try { + await releaseResumeReservation() + } catch (releaseError: unknown) { + reported = new Error( + `${errorChain(error)}; target reservation release failed: ${errorChain(releaseError)}`, + ) + } + if (terminalReleased) { ui.start() ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error') + } else { + await overlay.close() + resumeOverlay = undefined + appendNotice(`Resume failed: ${errorChain(reported)}`, 'error') } } - } catch (error: unknown) { - /* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */ - if (!disposed) { - await overlay.close() - resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(error)}`, 'error') - } } finally { resumeInFlight = false } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6b9bd143d4..22692026e5 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -10,6 +10,7 @@ import AgentRegistry, { import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -53,6 +54,7 @@ export interface TuiHarnessOptions { list(): Promise<SessionHeader[]> load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }> isLive?(id: ReturnType<typeof SessionId>): Promise<boolean> + claimLive?(id: ReturnType<typeof SessionId>): Promise<SessionLiveLease> } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ @@ -138,7 +140,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e inspect: persistence.load === undefined ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType<typeof SessionId>) => persistence.load!(id), - claimLive: () => Promise.resolve({ release: () => Promise.resolve() }), + claimLive: persistence.claimLive === undefined + ? () => Promise.resolve({ release: () => Promise.resolve() }) + : (id: ReturnType<typeof SessionId>) => persistence.claimLive!(id), isLive: persistence.isLive === undefined ? () => Promise.resolve(false) : (id: ReturnType<typeof SessionId>) => persistence.isLive!(id), diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a6f8d30341..635446b041 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -562,6 +562,8 @@ describe('resume command and /resume', () => { it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { const target = header('target-session', 10, '/workspace') + const releaseReservation = vi.fn(() => Promise.resolve()) + const claimLive = vi.fn(async () => ({ release: releaseReservation })) const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process'))) const result = await setup({ cwd: '/workspace', @@ -569,6 +571,7 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Target session') }), + claimLive, }, }) result.terminal.send('/resume') @@ -579,6 +582,8 @@ describe('resume command and /resume', () => { await tick(); await tick() expect(handoff).toHaveBeenCalledTimes(1) expect(handoff).toHaveBeenCalledWith(target.id) + expect(claimLive).toHaveBeenCalledWith(target.id) + expect(releaseReservation).toHaveBeenCalledTimes(1) expect(result.terminal.stopped).toBeGreaterThan(0) expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') await dispose(result) @@ -630,6 +635,183 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('keeps the current TUI when the target reservation loses the preflight race', async () => { + const target = header('reservation-race', 10, '/workspace') + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const flush = vi.fn() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', flush) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Reservation race') }), + claimLive: () => Promise.reject(new Error('occupied after preflight')), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Reservation race') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume failed: occupied after preflight') + expect(flush).not.toHaveBeenCalled() + expect(handoff).not.toHaveBeenCalled() + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('refuses host handoff when a query backend has no persistence lease service', async () => { + const target = header('query-without-persistence', 10, '/workspace') + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: false, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Query without persistence'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Query without persistence') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('session persistence is not mounted') + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + + it('releases a reservation that resolves after TUI disposal', async () => { + const target = header('late-reservation', 10, '/workspace') + const claiming = Promise.withResolvers<{ release(): Promise<void> }>() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Late reservation') }), + claimLive: () => claiming.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Late reservation') + result.terminal.send('\r') + await tick() + await dispose(result) + claiming.resolve({ release }) + await tick() + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('does not hand off after disposal begins during the current-session flush', async () => { + const target = header('dispose-during-flush', 10, '/workspace') + const flushing = Promise.withResolvers<undefined>() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', () => flushing.promise) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during flush') + result.terminal.send('\r') + await tick() + const disposing = dispose(result) + await tick() + flushing.resolve(undefined) + await disposing + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('does not hand off after disposal begins while terminal input drains', async () => { + const target = header('dispose-during-drain', 10, '/workspace') + const draining = Promise.withResolvers<undefined>() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.drainInput.mockImplementationOnce(() => draining.promise) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during drain') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.drainInput).toHaveBeenCalled() }) + await dispose(result) + draining.resolve(undefined) + await tick() + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('reports a target reservation release failure after a recoverable host rejection', async () => { + const target = header('release-failure', 10, '/workspace') + let releases = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: () => Promise.reject(new Error('host rejected')), + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Release failure') }), + claimLive: async () => ({ + release: () => ++releases === 1 + ? Promise.reject(new Error('lock unavailable')) + : Promise.resolve(), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Release failure') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('target reservation release failed') + expect(result.terminal.output).toContain('release failed: lock') + await dispose(result) + expect(releases).toBe(2) + }) + it('rejects a candidate whose cwd changes between listing and preflight', async () => { const target = header('moving-workspace', 10, '/workspace') let listings = 0 From c440217fde2e5355c1ec44b3ade1ab2301fc7816 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 16:07:49 +0800 Subject: [PATCH 095/113] refactor(tui): defer cross-process resume locking --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 14 +- .../2026-07-21-tui-resume-command.zh.md | 14 +- apps/cli/README.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 6 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 24 +-- docs/core-data-structures/persistence.md | 12 -- docs/event-producer-consumer.md | 2 +- examples/tui-agent/README.md | 2 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 -- packages/core/agent-loop/src/index.ts | 31 +-- packages/core/agent-loop/tests/resume.spec.ts | 45 ----- packages/examples/tui-demo/README.md | 2 +- .../session-persistence-jsonl/README.md | 8 +- .../session-persistence-jsonl/src/index.ts | 122 +---------- .../tests/fixtures/live-lease-child.ts | 16 -- .../tests/fixtures/live-lease-race-child.ts | 33 --- .../tests/jsonl.spec.ts | 189 +----------------- .../session-persistence-sqlite/README.md | 5 +- .../session-persistence-sqlite/src/index.ts | 67 +------ .../session-persistence-sqlite/src/schema.ts | 11 +- .../tests/sqlite.spec.ts | 51 +---- .../session-persistence/README.md | 9 +- .../session-persistence/src/coordinator.ts | 86 +------- .../session-persistence/src/index.ts | 43 ---- .../session-persistence/src/lease.ts | 123 ------------ .../session-persistence/tests/lease.spec.ts | 92 --------- .../tests/persistence.spec.ts | 58 +----- packages/ui/tui/README.md | 3 +- packages/ui/tui/src/index.ts | 51 +---- packages/ui/tui/tests/harness.ts | 9 - packages/ui/tui/tests/tui.spec.ts | 152 +++++++------- scripts/gen-cordis-catalog.ts | 1 - scripts/type-equiv.manifest.json | 5 - 39 files changed, 133 insertions(+), 1183 deletions(-) delete mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts delete mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts delete mode 100644 packages/session-persistence/session-persistence/src/lease.ts delete mode 100644 packages/session-persistence/session-persistence/tests/lease.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index c04f198e4a..62dc61c019 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: cd08b56f1e887473fd1df9f5f6055cb7c5e0a9b4 -2026-07-21-tui-resume-command.zh.md: ef641292dd178e19f94b6f79d3ab8607da27ee6d +2026-07-21-tui-resume-command.md: 526c3775bcae1bae63fb37b097091f83cfc67afd +2026-07-21-tui-resume-command.zh.md: d333a5bb22057d3d035c22950a84f51e0ca0640d diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index cd08b56f1e..526c3775bc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -6,17 +6,15 @@ English | [中文](2026-07-21-tui-resume-command.zh.md) ## Problem -The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, detect another live owner, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement. +The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement. ## Decision -`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and another live owner's session remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. -`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly. +`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. -First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID or a same-PID different-incarnation owner, and release only after the exact session lifecycle drains. A final process-local release excludes reacquisition until the physical lease settles. `AgentLoop.resume()` claims before load, closing the preflight/start race. - -After preflight, the TUI claims the target's exec-stable live lease before flushing the current session. A lost claim race remains in the current TUI; any later recoverable failure releases the reservation. The TUI then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process while retaining the target reservation rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. +After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. @@ -32,10 +30,10 @@ After preflight, the TUI claims the target's exec-stable live lease before flush ## Consequences -- Persistence schema and artifact layout include live leases; SQLite advances its unreleased schema version and rejects older databases under the repository's pre-release policy. +- Concurrent processes can select or resume the same persisted session because preflight does not serialize them. - `/resume` depends on `session-query` for discovery and complete-log reads, but persistence and host handoff remain optional; without a host, the command fallback stays usable. - Process replacement intentionally restarts Loader composition. Runtime-only state is rebuilt, while only logged or header-backed session state survives. ## Testing -TUI tests cover keyboard navigation, title/id search, Escape cancellation, running-agent refusal, route absence, occupied and corrupt rows, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Persistence contracts retain valid/corrupt/interrupted behavior, while a real JSONL child process proves another owner is disabled and its crashed lease is reclaimed. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. +TUI tests cover keyboard navigation, title/id search, Escape cancellation, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index ef641292dd..d333a5bb22 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -6,17 +6,15 @@ Status: implemented ## Problem -原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失、发现另一个活跃所有者,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。 +原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。 ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和被另一个活跃进程占用的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 -`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 -第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 或 PID 相同但进程代际不同的租约,并且仅在对应会话生命周期完全停稳后释放租约。进程内最后一个引用开始释放后,新的领取操作必须等待物理租约完成释放再重新获取。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 - -预检通过后,TUI 会先领取目标会话在进程替换前后保持稳定的活跃租约,再刷写当前会话。如果目标在预检后被其他进程抢占,当前 TUI 会继续运行;之后任何可恢复失败也会释放该预留租约。随后 TUI 停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,同时保留目标预留租约,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 `resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 @@ -32,10 +30,10 @@ Status: implemented ## Consequences -- 持久化 schema 和产物布局均包含活跃会话租约;SQLite 会推进其尚未发布的 schema 版本,并根据仓库的预发布政策拒绝旧数据库。 +- 预检不会串行化不同进程;多个进程可以并发选择或恢复同一个持久化会话。 - `/resume` 依赖 `session-query` 发现会话并读取完整日志,但持久化和宿主交接仍是可选功能;没有宿主时,命令回退仍可使用。 - 进程替换会有意重启 Loader 组合。系统会重建仅存在于运行时的状态,而只有日志或会话头部记录的会话状态能够保留。 ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、agent 运行期间拒绝恢复、路由缺失、被占用或损坏的候选行、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。持久化契约继续覆盖有效、损坏和中断的会话;真实 JSONL 子进程则证明另一个所有者占用的会话不可选择,并且进程崩溃后遗留的租约可以回收。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 +TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 86bda3fbf5..e6a33247ca 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -5,7 +5,7 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are pro The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and atomically replaces the process with a normalized resume flag so only one runtime owns the terminal; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; +- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ea83179477..466a96dd30 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: f0ce115d0b6e07a14d3c28288ea78f2c2f4294e7 -architecture.zh.md: eef66e3b9df6a3f00a48fc2c6d2e942b3fa9fe42 +architecture.md: 5a0ff63413a0c2a59d042f935d341dd39234f669 +architecture.zh.md: e1fb143982ad968fe6be2a5f6154722a602a297b diff --git a/docs/architecture.md b/docs/architecture.md index f0ce115d0b..5a0ff63413 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,7 @@ The shipped loop runs prompt-to-checkpoint work through plugin services and even A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. -Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume claims a live lease before load, restores lineage and delegation depth before publication, and releases after quiescence. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent. +Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent. ### Turn Flow diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index eef66e3b9d..e1fb143982 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -67,7 +67,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 -未提供 id 时会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程在加载前领取活跃会话租约,在发布前还原沿袭关系和委托深度,并在系统停稳后释放租约。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 +未提供 id 时会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程会在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 ### 轮次流程 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6b841214c5..040b15baf3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -113,7 +113,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:378`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -957,7 +957,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:39`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -996,7 +996,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:59`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query-sqlite` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 9bda12f6b0..6becfd9434 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -399,7 +399,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:371`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f0ee05d705..5858799e7c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandl Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:416`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:398`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -961,29 +961,11 @@ abstract list(): Promise<SessionHeader[]> * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]> - -/** - * Atomically acquire this process's live ownership of a session id. - * Reentrant claims share one backend lease. First-party backends override - * this process-local fallback to reject another live process and reclaim a - * dead owner. - * @param id - session identity that is about to become live. - * @returns a single-release reference owned by the caller. - */ -claimLive(id: SessionId): Promise<SessionLiveLease> - -/** - * Check whether any process currently owns a live lease for this session. - * The base implementation reports only claims on this service instance. - * @param id - persisted or prospective session identity. - * @returns true while a non-stale lease exists, including this process's lease. - */ -isLive(id: SessionId): Promise<boolean> ``` -Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) +Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index ffd4304376..f45eb0417a 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -34,18 +34,6 @@ interface SessionLocation { } ``` -## `SessionLiveLease` — live ownership capability - -`claimLive(id)` returns one idempotent release capability. The base service tracks only its own process; first-party backends additionally reject another live process and reclaim a dead owner's lease. `isLive(id)` reports either local or backend ownership without claiming it. - -```ts type-equiv -/** Idempotent capability releasing one acquired live-session lease reference. */ -interface SessionLiveLease { - /** Release this caller's lease reference after its live session reaches quiescence. */ - release(): Promise<void> -} -``` - ## `SessionHeader` — metadata beside the log Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7f7ecc4f2d..179ad0dcee 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:371`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 032a82bdaf..2e87df0a27 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume <prior-session-id> ``` -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then atomically replaces the process with `dsh --resume <id>`; the terminal never has two owners. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume <id>`. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. ## Code Mode diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 9c198870ad..8c93a004c2 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -260,7 +260,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { - it('hands /resume to one exec-replaced terminal owner and restores the same session state', async () => { + it('exec-replaces the TUI for /resume and restores the same session state', async () => { const output = await smoke({ label: 'dsh in-place resume', tempDirPrefix: 'dsh-in-place-resume-', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f49cc75b1c..6939ec9927 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -480,14 +480,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>', jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */', }, - { - signature: 'claimLive(id: SessionId): Promise<SessionLiveLease>', - jsDoc: '/**\n * Atomically acquire this process\'s live ownership of a session id.\n * Reentrant claims share one backend lease. First-party backends override\n * this process-local fallback to reject another live process and reclaim a\n * dead owner.\n * @param id - session identity that is about to become live.\n * @returns a single-release reference owned by the caller.\n */', - }, - { - signature: 'isLive(id: SessionId): Promise<boolean>', - jsDoc: '/**\n * Check whether any process currently owns a live lease for this session.\n * The base implementation reports only claims on this service instance.\n * @param id - persisted or prospective session identity.\n * @returns true while a non-stale lease exists, including this process\'s lease.\n */', - }, ], }, { @@ -1817,10 +1809,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLineageTrace', declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', }, - { - name: 'SessionLiveLease', - declaration: 'export interface SessionLiveLease {\n release(): Promise<void>;\n}', - }, { name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 15ece492e3..bdaa3f2401 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -25,7 +25,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { bindReactLoopAgentContext, prepareReactLoopAgent, @@ -114,7 +114,6 @@ class AgentCreationTransaction { private scope: Scope | undefined private session: Session | undefined private lifecycleDispose: (() => Promise<void> | void) | undefined - private liveLease: SessionLiveLease | undefined private detachSession: (() => void) | undefined private detachAgent: (() => void) | undefined private publishing = false @@ -187,12 +186,6 @@ class AgentCreationTransaction { ]) } - /** Retain a pre-load persistence lease until this transaction fully tears down. */ - holdLiveLease(lease: SessionLiveLease): void { - this.assertActive() - this.liveLease = lease - } - /** Construct the driver and scope, then install their complete ordered lifecycle. */ prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { this.assertActive() @@ -226,11 +219,6 @@ class AgentCreationTransaction { // First yielded, disposed last. yield () => { this.finish() } yield scope.rawDispose - yield async () => { - const lease = this.liveLease - this.liveLease = undefined - await lease?.release() - } yield () => { this.detachSession?.() this.detachSession = undefined @@ -327,13 +315,7 @@ class AgentCreationTransaction { try { await this.scope?.dispose() } finally { - try { - const lease = this.liveLease - this.liveLease = undefined - await lease?.release() - } finally { - this.finish() - } + this.finish() } } })()) @@ -625,15 +607,6 @@ export class AgentLoop extends Service implements AgentFactory { options.signal, ) try { - const claiming = persistence.claimLive(options.resumeSessionId) - let lease: SessionLiveLease - try { - lease = await transaction.waitFor(claiming) - } catch (error) { - void claiming.then(claim => claim.release(), () => {}) - throw error - } - transaction.holdLiveLease(lease) const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId)) transaction.assertActive() const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 2d19db7a0d..fdf5c39514 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -391,51 +391,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) - it('owner unload during live-lease acquisition releases a late claim', async () => { - const sessionId = SessionId('resume-claim-owner-unload') - const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) - const claiming = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.claimLive>>>() - const claimStarted = Promise.withResolvers<undefined>() - const originalClaim = ctx.sessionPersistence.claimLive.bind(ctx.sessionPersistence) - ctx.sessionPersistence.claimLive = (id) => { - expect(id).toBe(sessionId) - claimStarted.resolve(undefined) - return claiming.promise - } - - let resuming!: ReturnType<typeof ctx.agents.resume> - const owner = await ctx.plugin(Object.assign((inner: Context) => { - resuming = inner.agents.resume({ resumeSessionId: sessionId }) - }, { inject: ['agents'] })) - await claimStarted.promise - const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) - await promptly(owner.dispose()) - await rejection - - let releases = 0 - claiming.resolve({ release: () => { releases += 1; return Promise.resolve() } }) - await Promise.resolve() - await Promise.resolve() - expect(releases).toBe(1) - ctx.sessionPersistence.claimLive = originalClaim - await ctx.fiber.dispose() - }) - - it('propagates a rejected live-lease claim without loading or publishing', async () => { - const sessionId = SessionId('resume-claim-rejected') - const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) - let loads = 0 - ctx.sessionPersistence.claimLive = () => Promise.reject(new Error('occupied elsewhere')) - ctx.sessionPersistence.load = () => { loads += 1; return Promise.reject(new Error('must not load')) } - await expect(ctx.agents.resume({ resumeSessionId: sessionId })) - .rejects.toThrow('occupied elsewhere') - expect(loads).toBe(0) - expect(ctx.agents.get(sessionId)).toBeUndefined() - await ctx.fiber.dispose() - }) - it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const root = await persistSession(sessionId) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index bba7ae7f7e..10ff7872c5 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -45,7 +45,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | | `resumeSessionId` | — | Exact persisted session to resume | -Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for safe in-place process handoff. +Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff. ## The bin diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 86a8c0f1d6..bf86bf8633 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,9 +6,6 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` <root>/ - .live/ - <encoded-id>.lock # PID + nonce cross-process live lease - <encoded-id>.lock.reclaim # ephemeral stale-owner takeover guard cwd-<sha256(cwd)[:12]>/ # per-project bucket (or _no-cwd/ when no cwd) <encoded-id>.jsonl.zstd # default: checksummed header frame + append frames <encoded-id>.jsonl # only with compression: 'none' @@ -46,7 +43,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the ## Write path -The plugin copies frozen session events into one controller per live session and starts an eager drain. Before a session can flush or resume, the coordinator claims an exclusive `.live/<encoded-id>.lock` containing the process PID and an exec-stable nonce; another live process is rejected, while a dead owner is reclaimed under the separate `.reclaim` guard. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Disposal drains every retained controller before releasing its lease. +The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown. ## Model Experience @@ -69,6 +66,5 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **Lease scope is local-host advisory ownership** — PID plus same-process nonce checks prevent two ordinary local Harness processes from resuming the same id, but foreign PID reuse remains fail-closed and this is not a distributed lease for shared network filesystems or hostile principals. -- **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active. +- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 8d96a17681..629c0e3ff1 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,9 +14,8 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseOwnerIsLive, shareSessionLiveLease, - type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, - type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -65,11 +64,6 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } -interface JsonlLiveLeaseRecord { - pid: number - nonce: string -} - /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' @@ -141,14 +135,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.inspect(id) } - override claimLive(id: SessionId): Promise<SessionLiveLease> { - return this.coordinator.claimLive(id) - } - - override isLive(id: SessionId): Promise<boolean> { - return this.coordinator.isLive(id) - } - // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -288,110 +274,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return snapshots } - /** Atomically publish one process lease, reclaiming a crashed owner's record. */ - async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> { - const path = this.liveLeasePath(id) - return shareSessionLiveLease(`jsonl:${path}`, () => this.acquireLiveFile(path, id, owner)) - } - - private async acquireLiveFile( - path: string, - id: SessionId, - owner: SessionLiveOwner, - ): Promise<() => Promise<void>> { - await mkdir(dirname(path), { recursive: true, mode: 0o700 }) - for (;;) { - try { - const handle = await open(path, 'wx', 0o600) - try { - await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8') - await handle.sync() - } finally { - await handle.close() - } - break - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - const current = await this.readLiveLease(path) - if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break - if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) { - throw new Error(`session "${id}" is occupied by another live process`) - } - const reclaimPath = `${path}.reclaim` - let reclaim: Awaited<ReturnType<typeof open>> - try { - reclaim = await open(reclaimPath, 'wx', 0o600) - } catch (reclaimError) { - /* v8 ignore else -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ - if ((reclaimError as NodeJS.ErrnoException).code === 'EEXIST') { - throw new Error(`session "${id}" live-lease reclamation is already in progress`) - } - /* v8 ignore next -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ - throw reclaimError - } - try { - /* v8 ignore start -- cross-process revalidation is covered by the two-process race test */ - const latest = await this.readLiveLease(path) - if (latest === undefined) { - if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`) - } else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) { - if (sessionLeaseOwnerIsLive(latest, owner)) { - throw new Error(`session "${id}" is occupied by another live process`) - } - await rm(path, { force: true }) - } - /* v8 ignore stop */ - } finally { - try { - await reclaim.close() - } finally { - await rm(reclaimPath, { force: true }) - } - } - } - } - return async () => { - const current = await this.readLiveLease(path) - if (current?.pid === owner.pid && current.nonce === owner.nonce) await rm(path, { force: true }) - } - } - - /** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */ - async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> { - const path = this.liveLeasePath(id) - const current = await this.readLiveLease(path) - if (current === undefined) return await this.exists(path) - if (current.pid === owner.pid && current.nonce === owner.nonce) return true - if (sessionLeaseOwnerIsLive(current, owner)) return true - return false - } - - private liveLeasePath(id: SessionId): string { - return join(this.root, '.live', `${encodeSegment(id)}.lock`) - } - - private async readLiveLease(path: string): Promise<JsonlLiveLeaseRecord | undefined> { - let text: string - try { - text = await readFile(path, 'utf8') - } catch (error) { - if (isENOENT(error)) return undefined - throw error - } - let value: unknown - try { - value = JSON.parse(text) - } catch { - return undefined - } - if (typeof value !== 'object' || value === null - || !Number.isSafeInteger((value as { pid?: unknown }).pid) - || (value as { pid: number }).pid <= 0 - || typeof (value as { nonce?: unknown }).nonce !== 'string' - || (value as { nonce: string }).nonce.length === 0) return undefined - return value as JsonlLiveLeaseRecord - } - private async listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>> { await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts deleted file mode 100644 index 2a42b8c402..0000000000 --- a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Child process that holds one JSONL live-session lease until it is killed. */ - -import { writeFile } from 'node:fs/promises' -import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' - -const [root, marker] = process.argv.slice(2) -if (root === undefined || marker === undefined) throw new Error('usage: live-lease-child.ts <root> <marker>') - -const ctx = new Context() -await ctx.plugin(SessionStore) -await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) -await ctx.sessionPersistence.claimLive(SessionId('leased-session')) -await writeFile(marker, 'held') -await new Promise<never>(() => { setInterval(() => {}, 60_000) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts deleted file mode 100644 index b4ef3b6e59..0000000000 --- a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Child process competing to reclaim one stale JSONL live-session lease. */ - -import { access, writeFile } from 'node:fs/promises' -import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' - -const [root, gate, marker, rawId] = process.argv.slice(2) -if (root === undefined || gate === undefined || marker === undefined || rawId === undefined) { - throw new Error('usage: live-lease-race-child.ts <root> <gate> <marker> <session-id>') -} - -for (;;) { - try { - await access(gate) - break - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - await new Promise(resolve => setTimeout(resolve, 5)) - } -} - -const ctx = new Context() -await ctx.plugin(SessionStore) -await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) -try { - await ctx.sessionPersistence.claimLive(SessionId(rawId)) - await writeFile(marker, 'claimed') - await new Promise<never>(() => { setInterval(() => {}, 60_000) }) -} catch (error) { - await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`) - await ctx.fiber.dispose() -} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 269d56f886..2b49b7d55b 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,24 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { spawn } from 'node:child_process' import { Context } from 'cordis' -import { access, appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' let root: string const dirs: string[] = [] -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const leaseChild = fileURLToPath(new URL('./fixtures/live-lease-child.ts', import.meta.url)) -const leaseRaceChild = fileURLToPath(new URL('./fixtures/live-lease-race-child.ts', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } @@ -149,186 +142,6 @@ describe('SessionPersistenceJsonl: format helpers', () => { }) }) -describe('SessionPersistenceJsonl: cross-process live leases', () => { - it('reference-counts one physical lease across backend instances in the process', async () => { - const dir = await freshRoot() - const contexts = [new Context(), new Context()] - for (const ctx of contexts) { - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - } - try { - const first = await contexts[0]!.sessionPersistence.claimLive(SessionId('shared-live')) - const second = await contexts[1]!.sessionPersistence.claimLive(SessionId('shared-live')) - await first.release() - await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(true) - await second.release() - await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(false) - } finally { - await Promise.all(contexts.map(ctx => ctx.fiber.dispose())) - } - }) - - it('disables another live owner and reclaims its lease after the process exits', async () => { - const dir = await freshRoot() - const marker = join(dir, 'lease-held') - const child = spawn(process.execPath, ['--import', tsxLoader, leaseChild, dir, marker], { - cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], - }) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - try { - await vi.waitFor(() => access(marker), { timeout: 30_000 }) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - try { - await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(true) - await expect(ctx.sessionPersistence.claimLive(SessionId('leased-session'))) - .rejects.toThrow('occupied by another live process') - const closed = new Promise<void>(resolve => child.once('close', () => { resolve() })) - child.kill() - await closed - await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(false) - const leasePath = join(dir, '.live', `${encodeSegment('leased-session')}.lock`) - await writeFile(leasePath, `${JSON.stringify({ pid: child.pid, nonce: 'dead-owner' })}\n`) - const claim = await ctx.sessionPersistence.claimLive(SessionId('leased-session')) - await claim.release() - } finally { - await ctx.fiber.dispose() - } - } catch (error) { - throw new Error(`live-lease child failed: ${stderr}`, { cause: error }) - } finally { - if (child.exitCode === null && child.signalCode === null) child.kill() - } - }, 40_000) - - it('fails closed on malformed lease records and surfaces lease read errors', async () => { - const dir = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - const liveDir = join(dir, '.live') - await mkdir(liveDir, { recursive: true }) - try { - const malformed = [ - 'not json', - JSON.stringify(null), - JSON.stringify({ pid: 1.5, nonce: 'x' }), - JSON.stringify({ pid: 0, nonce: 'x' }), - JSON.stringify({ pid: process.pid, nonce: 1 }), - JSON.stringify({ pid: process.pid, nonce: '' }), - ] - for (const [index, content] of malformed.entries()) { - const id = SessionId(`malformed-${index}`) - const path = join(liveDir, `${encodeSegment(id)}.lock`) - await writeFile(path, content) - await expect(ctx.sessionPersistence.isLive(id)).resolves.toBe(true) - await expect(ctx.sessionPersistence.claimLive(id)).rejects.toThrow('occupied by another live process') - } - - const unreadable = SessionId('unreadable-lease') - await mkdir(join(liveDir, `${encodeSegment(unreadable)}.lock`)) - await expect(ctx.sessionPersistence.isLive(unreadable)).rejects.toThrow() - - const replaced = SessionId('replaced-release') - const claim = await ctx.sessionPersistence.claimLive(replaced) - const replacedPath = join(liveDir, `${encodeSegment(replaced)}.lock`) - await writeFile(replacedPath, JSON.stringify({ pid: process.pid, nonce: 'replacement' })) - await claim.release() - expect(await readFile(replacedPath, 'utf8')).toContain('replacement') - - const inherited = SessionId('inherited-owner') - const inheritedPath = join(liveDir, `${encodeSegment(inherited)}.lock`) - await writeFile(inheritedPath, JSON.stringify(sessionLiveOwner())) - await expect(ctx.sessionPersistence.isLive(inherited)).resolves.toBe(true) - const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited) - await inheritedClaim.release() - - const reusedPid = SessionId('reused-pid') - const reusedPidPath = join(liveDir, `${encodeSegment(reusedPid)}.lock`) - await writeFile(reusedPidPath, JSON.stringify({ pid: process.pid, nonce: 'prior-incarnation' })) - await expect(ctx.sessionPersistence.isLive(reusedPid)).resolves.toBe(false) - const reusedPidClaim = await ctx.sessionPersistence.claimLive(reusedPid) - await reusedPidClaim.release() - - await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300)))) - .rejects.toThrow() - - const guarded = SessionId('guarded-reclaim') - const guardedPath = join(liveDir, `${encodeSegment(guarded)}.lock`) - await writeFile(guardedPath, JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' })) - await writeFile(`${guardedPath}.reclaim`, 'busy') - await expect(ctx.sessionPersistence.claimLive(guarded)) - .rejects.toThrow('reclamation is already in progress') - } finally { - await ctx.fiber.dispose() - } - }) - - it('allows exactly one process to reclaim a stale lease', async () => { - const dir = await freshRoot() - const liveDir = join(dir, '.live') - await mkdir(liveDir, { recursive: true }) - const sessionId = SessionId('reclaim-race') - await writeFile( - join(liveDir, `${encodeSegment(sessionId)}.lock`), - JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }), - ) - const gate = join(dir, 'race-start') - const markers = [join(dir, 'race-a'), join(dir, 'race-b')] - const children = markers.map(marker => spawn( - process.execPath, - ['--import', tsxLoader, leaseRaceChild, dir, gate, marker, sessionId], - { - cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], - }, - )) - const errors = ['', ''] - children.forEach((child, index) => { - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { errors[index] = (errors[index] ?? '') + chunk }) - }) - try { - await writeFile(gate, 'go') - await vi.waitFor(() => Promise.all(markers.map(marker => access(marker))), { timeout: 30_000 }) - const outcomes = await Promise.all(markers.map(marker => readFile(marker, 'utf8'))) - expect(outcomes.filter(outcome => outcome === 'claimed')).toHaveLength(1) - expect(outcomes.filter(outcome => outcome.startsWith('rejected:'))).toHaveLength(1) - - const winner = children[outcomes.findIndex(outcome => outcome === 'claimed')]! - const loser = children[outcomes.findIndex(outcome => outcome.startsWith('rejected:'))]! - if (loser.exitCode === null && loser.signalCode === null) { - await new Promise<void>(resolve => loser.once('close', () => { resolve() })) - } - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - try { - await expect(ctx.sessionPersistence.claimLive(sessionId)) - .rejects.toThrow('occupied by another live process') - } finally { - await ctx.fiber.dispose() - } - const closed = new Promise<void>(resolve => winner.once('close', () => { resolve() })) - winner.kill() - await closed - } catch (error) { - throw new Error(`live-lease race children failed: ${errors.join('\n')}`, { cause: error }) - } finally { - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) child.kill() - } - } - }, 40_000) -}) - describe('SessionPersistenceJsonl: durability and crash semantics', () => { let ctx: Context beforeEach(async () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 42421b2e81..f1f4bc1f7b 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id, and `live_session_leases` stores one PID and exec-stable nonce per live session. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. @@ -33,7 +33,7 @@ interface Config { ## Write path -Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. A live lease is acquired in a `BEGIN IMMEDIATE` transaction before flush or resume and released after the exact lifecycle retires. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. +Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. ## Model Experience @@ -57,4 +57,3 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. - **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). -- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 091880e87e..5804c18282 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,9 +15,8 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseOwnerIsLive, shareSessionLiveLease, - type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, - type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -162,14 +161,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.inspect(id) } - override claimLive(id: SessionId): Promise<SessionLiveLease> { - return this.coordinator.claimLive(id) - } - - override isLive(id: SessionId): Promise<boolean> { - return this.coordinator.isLive(id) - } - // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -280,55 +271,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers })) } - /** Atomically acquire one SQLite-backed process lease. */ - async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> { - await this.ready - return shareSessionLiveLease( - `sqlite:${this.storeIdentity}:${id}`, - () => Promise.resolve().then(() => this.acquireLiveRow(id, owner)), - ) - } - - private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise<void> { - this.db.exec('BEGIN IMMEDIATE') - try { - const current = this.liveLeaseFor(id) - if (current !== undefined - && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { - if (sessionLeaseOwnerIsLive(current, owner)) { - throw new Error(`session "${id}" is occupied by another live process`) - } - this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id) - } - this.db.prepare(` - INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?) - ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce - `).run(id, owner.pid, owner.nonce) - this.db.exec('COMMIT') - } catch (error) { - this.db.exec('ROLLBACK') - throw error - } - return async () => { - await this.ready - this.db.prepare( - 'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?', - ).run(id, owner.pid, owner.nonce) - } - } - - /** Report a non-stale SQLite lease and remove a crashed owner's row. */ - async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> { - await this.ready - const current = this.liveLeaseFor(id) - if (current === undefined) return false - if ((current.pid === owner.pid && current.nonce === owner.nonce) - || sessionLeaseOwnerIsLive(current, owner)) return true - this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?') - .run(id, current.pid, current.nonce) - return false - } - /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ async close(): Promise<void> { await this.ready @@ -342,11 +284,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined } - private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined { - return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?') - .get(id) as { pid: number; nonce: string } | undefined - } - /** * Insert-or-replace a session's metadata row. The only caller is the first * materializing `appendBatch`, so writing the row IS the materialization (its diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 6a5be76eb9..8b8dcd78e0 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 9 +export const SCHEMA_VERSION = 8 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * rather than being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. - * @returns the open handle with pragmas applied and all tables ensured. + * @returns the open handle with pragmas applied and all three tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) @@ -128,13 +128,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM PRIMARY KEY (session_id, seq) ) STRICT `) - db.exec(` - CREATE TABLE IF NOT EXISTS live_session_leases ( - session_id TEXT PRIMARY KEY, - pid INTEGER NOT NULL, - nonce TEXT NOT NULL - ) STRICT - `) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index b670520a5c..3976e71549 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -7,16 +7,12 @@ import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' -import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] -afterEach(async () => { - vi.restoreAllMocks() - for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) -}) +afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> { try { @@ -446,7 +442,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(9) + expect(SCHEMA_VERSION).toBe(8) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -462,47 +458,6 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { - it('claims, rejects, reclaims, inspects, and releases SQLite live leases', async () => { - const path = await freshDbPath() - const b = await backend(path) - await b.ctx.sessionPersistence.list() - const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite - const owner = sessionLiveOwner() - const occupiedPid = process.pid + 1 - const originalKill = process.kill.bind(process) - vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { - if (pid === occupiedPid) return true - return originalKill(pid, signal) - }) - const db = openDatabase(path, 'wal') - const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') - insert.run('occupied-lease', occupiedPid, 'another-owner') - insert.run('reused-pid', process.pid, 'prior-incarnation') - insert.run('stale-claim', 2_147_483_647, 'dead-owner') - insert.run('stale-inspect', 2_147_483_647, 'dead-owner') - insert.run('owned-inspect', owner.pid, owner.nonce) - db.close() - - await expect(concrete.acquireLive(SessionId('occupied-lease'), owner)) - .rejects.toThrow('occupied by another live process') - const reused = await concrete.acquireLive(SessionId('reused-pid'), owner) - const claim = await concrete.acquireLive(SessionId('stale-claim'), owner) - expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true) - expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false) - expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false) - await claim() - await reused() - await b.dispose() - - const memory = new Context() - await memory.plugin(SessionStore) - await memory.plugin(SessionPersistenceSqlite, { path: ':memory:' }) - const memoryClaim = await memory.sessionPersistence.claimLive(SessionId('memory-live')) - expect(await memory.sessionPersistence.isLive(SessionId('memory-live'))).toBe(true) - await memoryClaim.release() - await memory.fiber.dispose() - }) - it('rejects and closes a current-schema database with an invalid store identity', async () => { const path = await freshDbPath() const db = openDatabase(path, 'wal') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 0aec3a4bd5..25429bd720 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -15,10 +15,6 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. | | `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | -| `claimLive(id): Promise<SessionLiveLease>` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. | -| `isLive(id): Promise<boolean>` | Report a current non-stale live lease, including one owned by this process. | - -The abstract base supplies a process-local fallback for lightweight third-party implementations. A backend that needs multi-process safety overrides both live-lease methods. ## Invariants every backend must honor @@ -35,7 +31,7 @@ Each `session/event` copies its event into the session controller and starts an Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state and the backend-owned live lease for that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, releases their leases, awaits per-id operations, and only then closes the storage handle. +When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. @@ -48,8 +44,6 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list()` | List all stored metadata. | -| `acquireLive?(id, owner)` | Atomically acquire a backend-owned cross-process lease and return its physical release. | -| `inspectLive?(id, owner)` | Report or reclaim a backend-owned lease without acquiring it. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). @@ -85,4 +79,3 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov - **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance. - **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale. - **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it. -- **Foreign PID reuse is fail-closed** — a claimant with the reused PID detects its different nonce and reclaims safely, but another process cannot observe that foreign process's private nonce and treats the PID as live until it exits or an operator verifies and removes the stale lease. diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 8ea1790b3e..fb46aa4877 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -8,8 +8,6 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { sessionLiveOwner } from './lease.ts' -import type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** * A stored session's header, valid contiguous event prefix, and optional opaque @@ -65,12 +63,6 @@ export interface PersistenceBackend<TornMarker = unknown> { /** List all stored (materialized) sessions' metadata. */ list(): Promise<SessionHeader[]> - /** Optionally acquire a backend-owned cross-process live-session lease. */ - acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> - - /** Optionally inspect and reclaim a backend-owned live-session lease. */ - inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise<boolean> - /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -104,7 +96,6 @@ interface LiveSessionState { pending: SessionEvent[] init: Promise<void> flush: Promise<void> | undefined - lease?: SessionLiveLease } /** Collect the rejection reasons from a set of promises (none-throwing). */ @@ -170,12 +161,6 @@ export class PersistenceCoordinator<TornMarker = unknown> { * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map<SessionId, Promise<unknown>>() - /** One backend lease with process-local reference counting per session id. */ - private liveClaims = new Map<SessionId, { - refs: number - releaseBackend: () => Promise<void> - }>() - private readonly liveOwner = sessionLiveOwner() constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) { this.installWritePath() @@ -288,61 +273,6 @@ export class PersistenceCoordinator<TornMarker = unknown> { return this.serialize(id, () => this.inspectCore(id)) } - /** - * Acquire one process-local reference to the backend's cross-process lease. - * @param id - session identity about to become live. - * @returns one idempotent release capability. - */ - async claimLive(id: SessionId): Promise<SessionLiveLease> { - const acquireLive = this.backend.acquireLive?.bind(this.backend) - if (acquireLive === undefined) return { release: () => Promise.resolve() } - await this.serialize(id, async () => { - const existing = this.liveClaims.get(id) - if (existing !== undefined) { - existing.refs += 1 - return - } - const releaseBackend = await acquireLive(id, this.liveOwner) - this.liveClaims.set(id, { refs: 1, releaseBackend }) - }) - let releaseTask: Promise<void> | undefined - return { - release: () => { - if (releaseTask !== undefined) return releaseTask - const task = this.serialize(id, async () => { - const claim = this.liveClaims.get(id) - /* v8 ignore next -- this capability is returned only after its claim enters the serialized map */ - if (claim === undefined) return - claim.refs -= 1 - if (claim.refs > 0) return - try { - await claim.releaseBackend() - } catch (error) { - claim.refs += 1 - throw error - } - this.liveClaims.delete(id) - }) - const wrapped = task.catch((error: unknown) => { - releaseTask = undefined - throw error - }) - releaseTask = wrapped - return wrapped - }, - } - } - - /** - * Check the backend's current cross-process lease state. - * @param id - session identity to inspect. - * @returns whether this or another live process owns the session. - */ - isLive(id: SessionId): Promise<boolean> { - if (this.liveClaims.has(id)) return Promise.resolve(true) - return this.backend.inspectLive?.(id, this.liveOwner) ?? Promise.resolve(false) - } - private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) @@ -452,9 +382,6 @@ export class PersistenceCoordinator<TornMarker = unknown> { let disposeError: unknown try { const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session))) - errors.push(...await settledErrors( - [...this.live.values()].flatMap(live => live.lease === undefined ? [] : [live.lease.release()]), - )) while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]) if (errors.length > 0) { throw new AggregateError(errors, `${this.backend.name} dispose failed`) @@ -514,8 +441,6 @@ export class PersistenceCoordinator<TornMarker = unknown> { private async retireCore(session: Session): Promise<void> { await this.flush(session) const id = session.header.id - const live = this.live.get(session) - await live?.lease?.release() await this.serialize(id, () => { this.live.delete(session) if (this.states.get(id)?.owner === session) this.states.delete(id) @@ -529,16 +454,7 @@ export class PersistenceCoordinator<TornMarker = unknown> { const seed = session.events.map(e => structuredClone(e)) const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } this.live.set(session, live) - live.init = this.claimLive(session.id).then(async (lease) => { - live.lease = lease - try { - await this.serialize(session.header.id, () => this.onCreated(session, seed)) - } catch (error) { - delete live.lease - await lease.release() - throw error - } - }) + live.init = this.serialize(session.header.id, () => this.onCreated(session, seed)) live.init.catch(() => { /* observed by flush/dispose through the controller */ }) return live } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index d6bbee0616..c785c9354c 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -8,18 +8,10 @@ import { Context, Service } from 'cordis' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' -import type { SessionLiveLease } from './lease.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' -export { - sessionLeaseOwnerIsLive, - sessionLeaseProcessIsLive, - sessionLiveOwner, - shareSessionLiveLease, -} from './lease.ts' -export type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { @@ -58,8 +50,6 @@ export interface SessionLocation { * rewriting committed events. */ export abstract class SessionPersistence extends Service { - private readonly localLiveClaims = new Map<SessionId, number>() - constructor(ctx: Context) { super(ctx, 'sessionPersistence') } @@ -133,39 +123,6 @@ export abstract class SessionPersistence extends Service { * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]> - - /** - * Atomically acquire this process's live ownership of a session id. - * Reentrant claims share one backend lease. First-party backends override - * this process-local fallback to reject another live process and reclaim a - * dead owner. - * @param id - session identity that is about to become live. - * @returns a single-release reference owned by the caller. - */ - claimLive(id: SessionId): Promise<SessionLiveLease> { - this.localLiveClaims.set(id, (this.localLiveClaims.get(id) ?? 0) + 1) - let released = false - return Promise.resolve({ - release: () => { - if (released) return Promise.resolve() - released = true - const refs = this.localLiveClaims.get(id) as number - if (refs <= 1) this.localLiveClaims.delete(id) - else this.localLiveClaims.set(id, refs - 1) - return Promise.resolve() - }, - }) - } - - /** - * Check whether any process currently owns a live lease for this session. - * The base implementation reports only claims on this service instance. - * @param id - persisted or prospective session identity. - * @returns true while a non-stale lease exists, including this process's lease. - */ - isLive(id: SessionId): Promise<boolean> { - return Promise.resolve(this.localLiveClaims.has(id)) - } } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts deleted file mode 100644 index 148d8e117f..0000000000 --- a/packages/session-persistence/session-persistence/src/lease.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** Process-backed identity helpers for cross-process live-session leases. */ - -import { randomUUID } from 'node:crypto' - -const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' - -/** Process identity stored in backend-owned cross-process live-session leases. */ -export interface SessionLiveOwner { - /** Operating-system process id; retained across an `execve` handoff. */ - readonly pid: number - /** Exec-stable process-start nonce used when the observer has the same PID. */ - readonly nonce: string -} - -/** Idempotent capability releasing one acquired live-session lease reference. */ -export interface SessionLiveLease { - /** Release this caller's lease reference after its live session reaches quiescence. */ - release(): Promise<void> -} - -/** - * Stable owner inherited only by an exec-replaced process, not inferred from a session id. - * @returns this process's PID and exec-stable nonce. - */ -export function sessionLiveOwner(): SessionLiveOwner { - const nonce = process.env[LIVE_OWNER_ENV] ?? randomUUID() - process.env[LIVE_OWNER_ENV] = nonce - return { pid: process.pid, nonce } -} - -/** - * Whether a lease pid still names a process; permission denial counts as live. - * @param pid - positive operating-system process id from a lease record. - * @returns true unless the operating system reports that the process is absent. - */ -export function sessionLeaseProcessIsLive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - -/** - * Whether a recorded owner still names this process incarnation or another live PID. - * A same-PID nonce mismatch proves reuse and is stale; an unrelated live PID is - * fail-closed because its private nonce is not observable across processes. - * @param recorded - owner stored in the backend lease. - * @param observer - identity of the process inspecting or claiming the lease. - * @returns whether the recorded owner must still be treated as live. - */ -export function sessionLeaseOwnerIsLive( - recorded: SessionLiveOwner, - observer: SessionLiveOwner, -): boolean { - if (recorded.pid === observer.pid) return recorded.nonce === observer.nonce - return sessionLeaseProcessIsLive(recorded.pid) -} - -interface SharedLeaseEntry { - refs: number - readonly acquired: Promise<() => Promise<void>> - finalizing?: Promise<void> -} - -const sharedLeases = new Map<string, SharedLeaseEntry>() - -/** - * Reference-count one physical lease across backend instances in this process. - * @param key - backend-kind plus canonical storage location and session id. - * @param acquire - single physical acquisition performed for the first reference. - * @returns an idempotent release for this caller's reference. - */ -export async function shareSessionLiveLease( - key: string, - acquire: () => Promise<() => Promise<void>>, -): Promise<() => Promise<void>> { - for (;;) { - let entry = sharedLeases.get(key) - if (entry?.finalizing !== undefined) { - await entry.finalizing - continue - } - if (entry === undefined) { - entry = { refs: 0, acquired: acquire() } - sharedLeases.set(key, entry) - void entry.acquired.catch(() => { - /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - }) - } - entry.refs += 1 - try { - await entry.acquired - } catch (error) { - entry.refs -= 1 - throw error - } - let releaseTask: Promise<void> | undefined - return () => { - if (releaseTask !== undefined) return releaseTask - const task = (async () => { - entry.refs -= 1 - if (entry.refs > 0 || sharedLeases.get(key) !== entry) return - const release = await entry.acquired - await release() - /* v8 ignore next -- claims wait for finalization before they can replace this exact entry */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - })() - const wrapped = task.catch((error: unknown) => { - entry.refs += 1 - /* v8 ignore next -- this closure is the sole writer of its release state until settlement */ - if (entry.finalizing === wrapped) delete entry.finalizing - releaseTask = undefined - throw error - }) - if (entry.refs === 0 && sharedLeases.get(key) === entry) entry.finalizing = wrapped - releaseTask = wrapped - return wrapped - } - } -} diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts deleted file mode 100644 index 8c38aac874..0000000000 --- a/packages/session-persistence/session-persistence/tests/lease.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { randomUUID } from 'node:crypto' -import { - sessionLeaseOwnerIsLive, - sessionLeaseProcessIsLive, - sessionLiveOwner, - shareSessionLiveLease, -} from '../src/lease.ts' - -const originalOwner = process.env.DSH_SESSION_LIVE_OWNER - -afterEach(() => { - vi.restoreAllMocks() - if (originalOwner === undefined) delete process.env.DSH_SESSION_LIVE_OWNER - else process.env.DSH_SESSION_LIVE_OWNER = originalOwner -}) - -describe('process live-session lease helpers', () => { - it('creates one exec-stable owner identity and classifies process liveness', () => { - delete process.env.DSH_SESSION_LIVE_OWNER - const first = sessionLiveOwner() - expect(first.pid).toBe(process.pid) - expect(typeof first.nonce).toBe('string') - expect(sessionLiveOwner()).toEqual(first) - expect(sessionLeaseOwnerIsLive(first, first)).toBe(true) - expect(sessionLeaseOwnerIsLive({ ...first, nonce: 'reused-pid' }, first)).toBe(false) - expect(sessionLeaseProcessIsLive(process.pid)).toBe(true) - - const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) - vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) - expect(sessionLeaseOwnerIsLive({ pid: 999_999, nonce: 'gone' }, first)).toBe(false) - vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) - expect(sessionLeaseProcessIsLive(999_999)).toBe(false) - const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) - vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied }) - expect(sessionLeaseProcessIsLive(999_998)).toBe(true) - }) - - it('shares one physical lease until every process-local reference releases', async () => { - const releasePhysical = vi.fn<() => Promise<void>>(() => Promise.resolve()) - const acquire = vi.fn<() => Promise<() => Promise<void>>>(() => Promise.resolve(releasePhysical)) - const key = `shared-${randomUUID()}` - const first = await shareSessionLiveLease(key, acquire) - const second = await shareSessionLiveLease(key, acquire) - expect(acquire).toHaveBeenCalledTimes(1) - await first() - expect(releasePhysical).not.toHaveBeenCalled() - await second() - await second() - expect(releasePhysical).toHaveBeenCalledTimes(1) - }) - - it('removes failed acquisitions and retries a failed physical release', async () => { - const key = `retry-${randomUUID()}` - await expect(shareSessionLiveLease(key, () => Promise.reject(new Error('claim failed')))) - .rejects.toThrow('claim failed') - - let releases = 0 - const release = await shareSessionLiveLease(key, () => Promise.resolve(async () => { - releases += 1 - if (releases === 1) throw new Error('release failed') - })) - await expect(release()).rejects.toThrow('release failed') - await expect(release()).resolves.toBeUndefined() - expect(releases).toBe(2) - }) - - it('waits for a final physical release before reacquiring the same key', async () => { - const key = `finalizing-${randomUUID()}` - const releaseGate = Promise.withResolvers<undefined>() - const firstPhysicalRelease = vi.fn(() => releaseGate.promise) - const secondPhysicalRelease = vi.fn(() => Promise.resolve()) - const releases: Array<() => Promise<void>> = [firstPhysicalRelease, secondPhysicalRelease] - let acquisitions = 0 - const acquire = vi.fn<() => Promise<() => Promise<void>>>((): Promise<() => Promise<void>> => { - const release = releases[acquisitions++] - if (release === undefined) throw new Error('unexpected physical acquisition') - return Promise.resolve(release) - }) - const first = await shareSessionLiveLease(key, acquire) - const finalizing = first() - const reacquiring = shareSessionLiveLease(key, acquire) - await Promise.resolve() - expect(acquire).toHaveBeenCalledTimes(1) - releaseGate.resolve(undefined) - await finalizing - const second = await reacquiring - expect(acquire).toHaveBeenCalledTimes(2) - await second() - expect(secondPhysicalRelease).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 36192c37d7..6b31d0843b 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -4,7 +4,7 @@ import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLiveOwner, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -348,46 +348,6 @@ describe('PersistenceCoordinator stored identity', () => { }) }) -describe('PersistenceCoordinator live leases', () => { - it('degrades without backend hooks and retries a failed final release', async () => { - const fallbackCtx = new Context() - await fallbackCtx.plugin(SessionStore) - const fallback = new PersistenceCoordinator(fallbackCtx, new ControlledBackend()) - const fallbackClaim = await fallback.claimLive(SessionId('fallback-live')) - expect(await fallback.isLive(SessionId('fallback-live'))).toBe(false) - await fallbackClaim.release() - await fallbackCtx.fiber.dispose() - - class LeaseBackend extends ControlledBackend { - releaseAttempts = 0 - async acquireLive(_id: SessionId, _owner: SessionLiveOwner): Promise<() => Promise<void>> { - return async () => { - this.releaseAttempts += 1 - if (this.releaseAttempts === 1) throw new Error('lease release failed') - } - } - inspectLive(): Promise<boolean> { - return Promise.resolve(true) - } - } - - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new LeaseBackend() - const coordinator = new PersistenceCoordinator(ctx, backend) - const first = await coordinator.claimLive(SessionId('leased')) - const second = await coordinator.claimLive(SessionId('leased')) - expect(await coordinator.isLive(SessionId('leased'))).toBe(true) - await first.release() - await expect(second.release()).rejects.toThrow('lease release failed') - await expect(second.release()).resolves.toBeUndefined() - await expect(second.release()).resolves.toBeUndefined() - expect(backend.releaseAttempts).toBe(2) - expect(await coordinator.isLive(SessionId('leased'))).toBe(true) - await ctx.fiber.dispose() - }) -}) - describe('PersistenceCoordinator retirement', () => { it('a retiring unmaterialized owner without buffered events releases its id', async () => { const ctx = new Context() @@ -835,20 +795,4 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() } }) - - it('provides a reference-counted process-local lease fallback', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(MemoryPersistence) - const id = SessionId('local-live') - const first = await ctx.sessionPersistence.claimLive(id) - const second = await ctx.sessionPersistence.claimLive(id) - expect(await ctx.sessionPersistence.isLive(id)).toBe(true) - await first.release() - await first.release() - expect(await ctx.sessionPersistence.isLive(id)).toBe(true) - await second.release() - expect(await ctx.sessionPersistence.isLive(id)).toBe(false) - await ctx.fiber.dispose() - }) }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index db13d8733e..8dc10f20f8 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output> `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, and claims the target live lease before flushing the current session; a lost claim race or later recoverable failure leaves the current TUI running and releases any acquired reservation. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process while retaining the reservation, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. @@ -156,6 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work +- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI. - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 90297fc478..372a0c9497 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -79,7 +79,7 @@ import type { } from '@deepseek-ai/dsh-session-query' // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. -import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, @@ -349,7 +349,7 @@ export interface TuiRuntime { formatCwd?: (cwd: string | undefined) => string /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ now?(): number - /** Host-owned safe process handoff; absent leaves `resumeCommand` as the fallback. */ + /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ handoffResume?: TuiResumeHost['handoff'] } @@ -1283,7 +1283,6 @@ interface ResumeRoute { interface ResumeCandidate { record: SessionRecord - occupied: boolean title: string lastActivityAt: number lastTurn: string @@ -1324,7 +1323,6 @@ function summarizeResumeCandidate( snapshot: SessionLogSnapshot, currentId: SessionId, cwd: string | undefined, - occupied: boolean, availableProviders: ReadonlySet<string>, ): ResumeCandidate { const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' @@ -1332,14 +1330,13 @@ function summarizeResumeCandidate( const foldedGoal = foldGoal(snapshot.events).goal let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' - else if (record.live || occupied) disabledReason = 'occupied by another live agent' + else if (record.live) disabledReason = 'session is already live in this runtime' else if (record.header.cwd !== cwd) disabledReason = 'different workspace' else if (route !== undefined && !availableProviders.has(route.provider)) { disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` } return { record, - occupied, title, lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, lastTurn: resumeTurnLabel(snapshot), @@ -1422,7 +1419,7 @@ class ResumeDialog implements Component, Focusable { const selected = index === this.selectedIndex const status = [ candidate.disabledReason === 'current session' ? 'current' : undefined, - candidate.record.live || candidate.occupied ? 'live' : undefined, + candidate.record.live ? 'live' : undefined, candidate.record.persisted ? 'persisted' : undefined, ].filter((value): value is string => value !== undefined).join(' · ') const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}` @@ -1841,8 +1838,6 @@ export function createTuiChat( let modelOverlay: TuiOverlaySession | undefined let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false - let resumeReservation: SessionLiveLease | undefined - let resumeReservationCommitted = false let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } @@ -1855,12 +1850,6 @@ export function createTuiChat( const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed - const releaseResumeReservation = async (): Promise<void> => { - const reservation = resumeReservation - if (reservation === undefined) return - await reservation.release() - resumeReservation = undefined - } // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2444,8 +2433,6 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() - /* v8 ignore else -- the committed branch is the non-returning exec handoff covered by the keyless PTY test */ - if (!resumeReservationCommitted) await releaseResumeReservation() contextResolution = undefined clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) @@ -2825,9 +2812,6 @@ export function createTuiChat( providers: ReadonlySet<string>, ): Promise<ResumeCandidate> => { try { - const occupied = record.live || (record.persisted && persistence !== undefined - ? await persistence.isLive(record.header.id) - : false) let snapshot: SessionLogSnapshot const live = ctx.sessions.get(record.header.id) if (live !== undefined) { @@ -2845,13 +2829,11 @@ export function createTuiChat( snapshot, agent.session.id, agent.session.header.cwd, - occupied, providers, ) } catch (error: unknown) { return { record, - occupied: record.live, title: 'Unreadable session', lastActivityAt: record.header.createdAt, lastTurn: 'log unavailable', @@ -2895,14 +2877,8 @@ export function createTuiChat( : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } - if (persistence === undefined) { - throw new Error('Resume is unavailable: session persistence is not mounted.') - } - resumeReservation = await persistence.claimLive(checked.record.header.id) - if (disposed) { - await releaseResumeReservation() - return - } + /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ + if (disposed) return await ctx.sessions.flush(agent.session) // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -2916,29 +2892,18 @@ export function createTuiChat( if (disposed) return ui.stop() terminalReleased = true - resumeReservationCommitted = true await hostHandoff(checked.record.header.id) throw new Error('resume host returned without replacing the process') } catch (error: unknown) { - /* v8 ignore next -- a committed host disposes this TUI and never returns; recoverable rejection keeps it live */ if (!disposed) { - resumeReservationCommitted = false - let reported = error - try { - await releaseResumeReservation() - } catch (releaseError: unknown) { - reported = new Error( - `${errorChain(error)}; target reservation release failed: ${errorChain(releaseError)}`, - ) - } if (terminalReleased) { ui.start() ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error') + appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') } else { await overlay.close() resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(reported)}`, 'error') + appendNotice(`Resume failed: ${errorChain(error)}`, 'error') } } } finally { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 22692026e5..97d0947536 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -10,7 +10,6 @@ import AgentRegistry, { import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' -import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -53,8 +52,6 @@ export interface TuiHarnessOptions { sessionPersistence?: { list(): Promise<SessionHeader[]> load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }> - isLive?(id: ReturnType<typeof SessionId>): Promise<boolean> - claimLive?(id: ReturnType<typeof SessionId>): Promise<SessionLiveLease> } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ @@ -140,12 +137,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e inspect: persistence.load === undefined ? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType<typeof SessionId>) => persistence.load!(id), - claimLive: persistence.claimLive === undefined - ? () => Promise.resolve({ release: () => Promise.resolve() }) - : (id: ReturnType<typeof SessionId>) => persistence.claimLive!(id), - isLive: persistence.isLive === undefined - ? () => Promise.resolve(false) - : (id: ReturnType<typeof SessionId>) => persistence.isLive!(id), } as never) } if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 635446b041..0ec1d4a6e2 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -396,7 +396,7 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('keeps persisted query records readable when live-lease inspection is unavailable', async () => { + it('keeps persisted query records readable without a persistence service', async () => { const target = header('query-only-persisted', 10, '/workspace') const result = await setup({ cwd: '/workspace', @@ -503,21 +503,19 @@ describe('resume command and /resume', () => { expect(result.terminal.stopped).toBeGreaterThan(0) }) - it('preflights route availability and occupied or corrupt sessions without losing the current TUI', async () => { + it('preflights route availability and corrupt sessions without losing the current TUI', async () => { const missing = header('missing-route', 10, '/workspace') - const occupied = header('occupied', 20, '/workspace') const corrupt = header('corrupt', 30, '/workspace') const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME }, sessionPersistence: { - list: async () => [missing, occupied, corrupt], - isLive: async id => id === occupied.id, + list: async () => [missing, corrupt], load: async (id) => { if (id === corrupt.id) throw new Error('checksum mismatch') return { - meta: id === missing.id ? missing : occupied, - events: resumeEvents(id === missing.id ? 'Missing adapter' : 'Busy session', id === missing.id ? 'absent-provider' : 'deepseek'), + meta: missing, + events: resumeEvents('Missing adapter', 'absent-provider'), } }, }, @@ -527,7 +525,6 @@ describe('resume command and /resume', () => { await tick(); await tick() expect(result.terminal.output).toContain('Missing adapter') expect(result.terminal.output).toContain('absent-provider/model-1') - expect(result.terminal.output).toContain('Busy session') expect(result.terminal.output).toContain('Unreadable session') result.terminal.send('Missing adapter') result.terminal.send('\r') @@ -537,6 +534,38 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('keeps a session already live in this runtime visible but disabled', async () => { + const target = header('live-target', 10, '/workspace') + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: true, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Live target'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Live target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('session is already live in this runtime') + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + it('falls back to assistant provenance and header creation time for sparse logs', async () => { const assistantOnly = header('assistant-route', 20, '/workspace') const empty = header('empty-log', 10, '/workspace') @@ -562,8 +591,6 @@ describe('resume command and /resume', () => { it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { const target = header('target-session', 10, '/workspace') - const releaseReservation = vi.fn(() => Promise.resolve()) - const claimLive = vi.fn(async () => ({ release: releaseReservation })) const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process'))) const result = await setup({ cwd: '/workspace', @@ -571,7 +598,6 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Target session') }), - claimLive, }, }) result.terminal.send('/resume') @@ -582,8 +608,6 @@ describe('resume command and /resume', () => { await tick(); await tick() expect(handoff).toHaveBeenCalledTimes(1) expect(handoff).toHaveBeenCalledWith(target.id) - expect(claimLive).toHaveBeenCalledWith(target.id) - expect(releaseReservation).toHaveBeenCalledTimes(1) expect(result.terminal.stopped).toBeGreaterThan(0) expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') await dispose(result) @@ -635,39 +659,46 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('keeps the current TUI when the target reservation loses the preflight race', async () => { - const target = header('reservation-race', 10, '/workspace') + it('does not flush or hand off when disposal begins during selected-session preflight', async () => { + const target = header('dispose-during-preflight', 10, '/workspace') + const secondListing = Promise.withResolvers<SessionRecord[]>() const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() const flush = vi.fn() + let listings = 0 + const record: SessionRecord = { header: target, live: false, persisted: true } const result = await setup({ cwd: '/workspace', handoffResume: handoff, async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.on('session/flush', flush) - }, - sessionPersistence: { - list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents('Reservation race') }), - claimLive: () => Promise.reject(new Error('occupied after preflight')), + ctx.provide('sessionQuery', { + listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise, + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Dispose during preflight'), + }), + } as never) }, }) result.terminal.send('/resume') result.terminal.send('\r') - await tick(); await tick() - result.terminal.send('Reservation race') + await tick() + result.terminal.send('Dispose during preflight') result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('Resume failed: occupied after preflight') + await vi.waitFor(() => { expect(listings).toBe(2) }) + await dispose(result) + secondListing.resolve([record]) + await tick() expect(flush).not.toHaveBeenCalled() expect(handoff).not.toHaveBeenCalled() - expect(result.terminal.stopped).toBe(0) - await dispose(result) }) - it('refuses host handoff when a query backend has no persistence lease service', async () => { + it('hands off a validated session exposed by a query backend without a persistence service', async () => { const target = header('query-without-persistence', 10, '/workspace') - const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>( + () => Promise.reject(new Error('test host retained process')), + ) const result = await setup({ cwd: '/workspace', handoffResume: handoff, @@ -692,42 +723,14 @@ describe('resume command and /resume', () => { result.terminal.send('Query without persistence') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('session persistence is not mounted') - expect(handoff).not.toHaveBeenCalled() + expect(handoff).toHaveBeenCalledWith(target.id) + expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') await dispose(result) }) - it('releases a reservation that resolves after TUI disposal', async () => { - const target = header('late-reservation', 10, '/workspace') - const claiming = Promise.withResolvers<{ release(): Promise<void> }>() - const release = vi.fn(() => Promise.resolve()) - const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() - const result = await setup({ - cwd: '/workspace', - handoffResume: handoff, - sessionPersistence: { - list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents('Late reservation') }), - claimLive: () => claiming.promise, - }, - }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick(); await tick() - result.terminal.send('Late reservation') - result.terminal.send('\r') - await tick() - await dispose(result) - claiming.resolve({ release }) - await tick() - expect(release).toHaveBeenCalledTimes(1) - expect(handoff).not.toHaveBeenCalled() - }) - it('does not hand off after disposal begins during the current-session flush', async () => { const target = header('dispose-during-flush', 10, '/workspace') const flushing = Promise.withResolvers<undefined>() - const release = vi.fn(() => Promise.resolve()) const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() const result = await setup({ cwd: '/workspace', @@ -739,7 +742,6 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }), - claimLive: async () => ({ release }), }, }) result.terminal.send('/resume') @@ -752,14 +754,12 @@ describe('resume command and /resume', () => { await tick() flushing.resolve(undefined) await disposing - expect(release).toHaveBeenCalledTimes(1) expect(handoff).not.toHaveBeenCalled() }) it('does not hand off after disposal begins while terminal input drains', async () => { const target = header('dispose-during-drain', 10, '/workspace') const draining = Promise.withResolvers<undefined>() - const release = vi.fn(() => Promise.resolve()) const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() const result = await setup({ cwd: '/workspace', @@ -767,7 +767,6 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }), - claimLive: async () => ({ release }), }, }) result.terminal.drainInput.mockImplementationOnce(() => draining.promise) @@ -780,36 +779,33 @@ describe('resume command and /resume', () => { await dispose(result) draining.resolve(undefined) await tick() - expect(release).toHaveBeenCalledTimes(1) expect(handoff).not.toHaveBeenCalled() }) - it('reports a target reservation release failure after a recoverable host rejection', async () => { - const target = header('release-failure', 10, '/workspace') - let releases = 0 + it('does not restart the terminal when a pending host rejects during disposal', async () => { + const target = header('host-rejects-during-disposal', 10, '/workspace') + const host = Promise.withResolvers<never>() + const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => host.promise) const result = await setup({ cwd: '/workspace', - handoffResume: () => Promise.reject(new Error('host rejected')), + handoffResume: handoff, sessionPersistence: { list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents('Release failure') }), - claimLive: async () => ({ - release: () => ++releases === 1 - ? Promise.reject(new Error('lock unavailable')) - : Promise.resolve(), - }), + load: async () => ({ meta: target, events: resumeEvents('Host disposal') }), }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - result.terminal.send('Release failure') + result.terminal.send('Host disposal') result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('target reservation release failed') - expect(result.terminal.output).toContain('release failed: lock') + await vi.waitFor(() => { expect(handoff).toHaveBeenCalled() }) + const startsBeforeDispose = result.terminal.started await dispose(result) - expect(releases).toBe(2) + host.reject(new Error('host rejected after disposal')) + await tick() + expect(result.terminal.started).toBe(startsBeforeDispose) + expect(result.terminal.output).not.toContain('host rejected after disposal') }) it('rejects a candidate whose cwd changes between listing and preflight', async () => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6be2d7ed83..d87fba3d08 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -95,7 +95,6 @@ export const LINK_MAP: Record<string, string> = { CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', - SessionLiveLease: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1472499819..db5fc0b85d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -379,11 +379,6 @@ "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" }, - { - "doc": "docs/core-data-structures/persistence.md", - "symbol": "SessionLiveLease", - "source": "packages/session-persistence/session-persistence/src/lease.ts" - }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", From d3b00bbdff2d16727c651632d2b0c5afb67983c8 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 16:13:13 +0800 Subject: [PATCH 096/113] test(tui): await fresh model selector frames --- packages/ui/tui/tests/tui.spec.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 0ec1d4a6e2..c6426d4c67 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2249,22 +2249,27 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') + const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') + await vi.waitFor(() => { + expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() result.agent.status = 'running' + const runningSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') - expect(result.terminal.output).toContain('alpha/a1') - expect(result.terminal.output).toContain('Alpha One — Fast — current') + await vi.waitFor(() => { + const output = result.terminal.output.slice(runningSelectorOutput) + expect(output).toContain('Select model') + expect(output).toContain('alpha/a1') + expect(output).toContain('Alpha One — Fast — current') + }) result.terminal.send('\x1b[B') result.terminal.send('\x1b[B') result.terminal.send('\r') @@ -2276,9 +2281,12 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).not.toContain('50% context tools:collapsed') + const cancelledSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await tick() + await vi.waitFor(() => { + expect(result.terminal.output.slice(cancelledSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() expect(result.agent.cancelled).not.toContain('cancelled from terminal') From 33ee34b58ea0d46601e544b589133f48f8ae580f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:40:17 -0700 Subject: [PATCH 097/113] fix(tui): make resume picker full-screen --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 4 +- .../2026-07-21-tui-resume-command.zh.md | 4 +- docs/config-catalog.md | 6 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/src/index.ts | 146 +++++++++++------- .../snapshots/resume-sessions.expected.txt | 112 ++++++-------- packages/ui/tui/tests/tui.spec.ts | 20 +-- 9 files changed, 156 insertions(+), 146 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 62dc61c019..d470b61414 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 526c3775bcae1bae63fb37b097091f83cfc67afd -2026-07-21-tui-resume-command.zh.md: d333a5bb22057d3d035c22950a84f51e0ca0640d +2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b +2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 526c3775bc..86f62e16f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -10,7 +10,7 @@ The original `/resume` printed shell commands. It did not let a keyboard user in ## Decision -`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. `session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. @@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re ## Testing -TUI tests cover keyboard navigation, title/id search, Escape cancellation, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. +TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index d333a5bb22..06e58f8144 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 `session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 @@ -36,4 +36,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 +TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 040b15baf3..49a0a1510f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1609,10 +1609,6 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number - /** Resume-selector width in terminal columns. */ - resumeDialogWidth?: number - /** Resume-selector maximum height in terminal rows. */ - resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -1635,7 +1631,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:278`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 8c93a004c2..efbe11099f 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -270,7 +270,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ { waitFor: 'scripted TUI ready.', send: '/resume\r' }, { waitFor: 'Resume selector design', send: 'Resume selector design' }, - { waitFor: 'Search: Resume selector design', send: '\r' }, + { waitFor: '⌕ Resume selector design', send: '\r' }, { waitFor: 'Preserve restored state', send: '/exit\r' }, ], }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 8dc10f20f8..3dae79bf73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output> `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. @@ -49,8 +49,6 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output> | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | -| `resumeDialogWidth` | `88` | Resume-selector width in columns | -| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 372a0c9497..2f7aab15af 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -205,10 +205,6 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number - /** Resume-selector width in terminal columns. */ - resumeDialogWidth?: number - /** Resume-selector maximum height in terminal rows. */ - resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -239,8 +235,6 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const resumeDialogWidthSchema = z.number().step(1).min(36).default(88) -const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -260,8 +254,6 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, - resumeDialogWidth: resumeDialogWidthSchema, - resumeDialogMaxHeight: resumeDialogMaxHeightSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -302,8 +294,6 @@ export const Config: z<Config> = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, - resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth, - resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -324,8 +314,6 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number - resumeDialogWidth: number - resumeDialogMaxHeight: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -370,8 +358,6 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 72, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, - resumeDialogWidth: config?.resumeDialogWidth ?? 88, - resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], @@ -1346,9 +1332,9 @@ function summarizeResumeCandidate( } } -/** Searchable keyboard selector over detached, preflighted resume summaries. */ -class ResumeDialog implements Component, Focusable { - private query = '' +/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +class ResumePicker implements Component, Focusable { + private readonly search = new Input() private selectedIndex = 0 private error = '' focused = false @@ -1356,87 +1342,133 @@ class ResumeDialog implements Component, Focusable { constructor( private readonly candidates: readonly ResumeCandidate[], private readonly maxVisible: number, + private readonly workspaceLabel: string, + private readonly viewportRows: () => number, private readonly palette: Palette, private readonly done: (candidate: ResumeCandidate) => void, private readonly cancel: () => void, ) {} - invalidate(): void {} + invalidate(): void { + this.search.invalidate() + } private filtered(): ResumeCandidate[] { - const query = this.query.trim().toLocaleLowerCase() + const query = this.search.getValue().trim().toLocaleLowerCase() if (query === '') return [...this.candidates] return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) || candidate.record.header.id.toLocaleLowerCase().includes(query)) } handleInput(data: string): void { - this.invalidate() const filtered = this.filtered() - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + if (matchesKey(data, Key.ctrl('c'))) { this.cancel() return } - if (matchesKey(data, Key.up)) { + if (matchesKey(data, Key.escape)) { + if (this.search.getValue() === '') this.cancel() + else { + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' + } + } else if (matchesKey(data, Key.up)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + filtered.length - 1) % filtered.length } else if (matchesKey(data, Key.down)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.pageUp)) { + this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible) + } else if (matchesKey(data, Key.pageDown)) { + this.selectedIndex = Math.min( + Math.max(0, filtered.length - 1), + this.selectedIndex + this.maxVisible, + ) } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] if (selected === undefined) this.error = 'No session matches this search.' else if (selected.disabledReason !== undefined) this.error = selected.disabledReason else this.done(selected) - } else if (data === '\x7f' || data === '\b') { - this.query = Array.from(this.query).slice(0, -1).join('') - this.selectedIndex = 0 - this.error = '' - } else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) { - this.query += data - this.selectedIndex = 0 - this.error = '' + } else { + const previous = this.search.getValue() + this.search.focused = this.focused + this.search.handleInput(data) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } } + this.invalidate() } render(width: number): string[] { - const innerWidth = Math.max(1, width - 4) + this.search.focused = this.focused + const height = Math.max(1, this.viewportRows()) + const horizontalPadding = width >= 12 ? 2 : 0 + const contentWidth = Math.max(1, width - horizontalPadding * 2) + const indent = ' '.repeat(horizontalPadding) const filtered = this.filtered() if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - filtered.length - this.maxVisible, - )) - const end = Math.min(filtered.length, start + this.maxVisible) - const body: string[] = [ - this.query === '' - ? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}` - : this.palette.text(`Search: ${displayText(this.query)}`), + const selected = filtered[this.selectedIndex] + const position = selected === undefined ? 0 : this.selectedIndex + 1 + const lines: string[] = [ + '', + `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, '', ] + + const searchInnerWidth = Math.max(1, contentWidth - 4) + lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) + const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, '⌕ ') + const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') + lines.push( + `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, + `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, + '', + `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, + '', + ) + + const candidateBudget = Math.max(1, Math.floor((height - 13) / 4)) + const visibleCount = Math.min(this.maxVisible, candidateBudget) + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(visibleCount / 2), + filtered.length - visibleCount, + )) + const end = Math.min(filtered.length, start + visibleCount) + const push = (line: string): void => { + lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) + } for (let index = start; index < end; index += 1) { const candidate = filtered[index] as ResumeCandidate - const selected = index === this.selectedIndex + const active = index === this.selectedIndex const status = [ candidate.disabledReason === 'current session' ? 'current' : undefined, candidate.record.live ? 'live' : undefined, candidate.record.persisted ? 'persisted' : undefined, ].filter((value): value is string => value !== undefined).join(' · ') - const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}` - body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead) + const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` + push(active ? this.palette.bold(this.palette.accent(lead)) : lead) const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) if (candidate.disabledReason !== undefined) { - body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) } } - if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.')) - if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`)) - body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel')) - if (this.error !== '') body.push(this.palette.error(displayText(this.error))) - return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette) + if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) + if (this.error !== '') { + lines.push('') + push(this.palette.error(displayText(this.error))) + } + + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + while (lines.length < height - 2) lines.push('') + lines.push(footer, '') + return lines.slice(0, height) } } @@ -2932,18 +2964,20 @@ export function createTuiChat( || a.record.header.id.localeCompare(b.record.header.id)) if (isDisposed() || scan !== resumeScan) return const session = overlayManager.open({ - create: () => new ResumeDialog( + create: host => new ResumePicker( candidates, resolved.maxResumeOptions, + runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + () => host.viewport.rows, palette, (candidate) => { void handoffResume(candidate, session) }, () => { void session.close() }, ), options: { - width: resolved.resumeDialogWidth, - maxHeight: resolved.resumeDialogMaxHeight, - anchor: 'center', - margin: 1, + width: '100%', + maxHeight: '100%', + anchor: 'top-left', + margin: 0, }, }) resumeOverlay = session diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index d78aa6d31f..db54654115 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -1,69 +1,51 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=31 bufferRow=31 +cursor hidden column=6 viewportRow=4 bufferRow=4 buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-8| <blank> -9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ " - style 2-89 fg=bright-blue -10| " │ Search: title or session id │ " - style 2-2 fg=bright-blue - style 4-10 fg=bright-black - style 12-30 dim - style 89-89 fg=bright-blue -11| " │ │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -12| " │ › Untitled session │ " - style 2-2 fg=bright-blue - style 4-21 fg=bright-blue bold - style 89-89 fg=bright-blue -13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ " - style 2-2 fg=bright-blue - style 4-69 fg=bright-black - style 89-89 fg=bright-blue -14| " │ current · live · main-session │ " - style 2-2 fg=bright-blue - style 4-34 dim - style 89-89 fg=bright-blue -15| " │ unavailable: current session │ " - style 2-2 fg=bright-blue - style 4-33 fg=yellow - style 89-89 fg=bright-blue -16| " │ Resume selector design │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ " - style 2-2 fg=bright-blue - style 4-76 fg=bright-black - style 89-89 fg=bright-blue -18| " │ persisted · earlier-session │ " - style 2-2 fg=bright-blue - style 4-32 dim - style 89-89 fg=bright-blue -19| " │ │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ " - style 2-2 fg=bright-blue - style 4-60 dim - style 89-89 fg=bright-blue -21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " - style 2-89 fg=bright-blue -22-31| <blank> +0| " " +1| " Resume session (1 of 2) " + style 2-24 fg=bright-blue bold +2| " " +3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ " + style 2-89 dim +4| " │ ⌕ │ " + style 2-2 dim + style 6-6 inverse + style 89-89 dim +5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " + style 2-89 dim +6| " " +7| " /workspace/project " + style 2-19 fg=bright-black +8| " " +9| " ❯ Untitled session " + style 2-19 fg=bright-blue bold +10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable " + style 2-67 fg=bright-black +11| " current · live · main-session " + style 2-32 dim +12| " unavailable: current session " + style 2-31 fg=yellow +13| " Resume selector design " +14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro " + style 2-74 fg=bright-black +15| " persisted · earlier-session " + style 2-30 dim +16| " " +17| " " +18| " " +19| " " +20| " " +21| " " +22| " " +23| " " +24| " " +25| " " +26| " " +27| " " +28| " " +29| " " +30| " Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel " + style 2-70 dim +31| " " diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c6426d4c67..127d5c8807 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -159,8 +159,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, - resumeDialogWidth: 88, - resumeDialogMaxHeight: 24, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -179,8 +177,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, - resumeDialogWidth: 84, - resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -198,8 +194,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, - resumeDialogWidth: 84, - resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -269,7 +263,7 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => { + it('opens a newest-active-first searchable selector and Esc clears before cancelling', async () => { const older = header('older-session', 500, '/workspace') const newer = header('newer-session', 2000, '/workspace') const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>() @@ -296,7 +290,11 @@ describe('resume command and /resume', () => { expect(output).not.toContain('foreign-session') result.terminal.send('Older') await tick() - expect(result.terminal.output).toContain('Search: Older') + expect(result.terminal.output).toContain('⌕ Older') + result.terminal.send('\x1b') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .not.toContain('⌕ Older') result.terminal.send('\x1b') await tick() expect(handoff).not.toHaveBeenCalled() @@ -325,7 +323,9 @@ describe('resume command and /resume', () => { result.terminal.send('\x7f') result.terminal.send('\x7f') await tick() - expect(result.terminal.output).toContain('Search: title or session id') + const cleared = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(cleared).toContain('⌕ ') + expect(cleared).not.toContain('zz') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('current session') @@ -349,7 +349,7 @@ describe('resume command and /resume', () => { result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('1/3') + expect(result.terminal.output).toContain('(1 of 3)') await dispose(result) }) From 12dbc001665e472d2f970a267fc9cc150ecb01c9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:15:26 -0700 Subject: [PATCH 098/113] fix(tui): harden resume picker input --- packages/ui/tui/src/index.ts | 56 ++++++++++++++++++++++++++--- packages/ui/tui/tests/tui.spec.ts | 59 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2f7aab15af..2ade1937e3 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -393,6 +393,11 @@ function ansi(open: string, close: string, enabled: boolean): (text: string) => } const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu +const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu +const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu +const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu +const BRACKETED_PASTE_START = '\u001B[200~' +const BRACKETED_PASTE_END = '\u001B[201~' /** * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. @@ -408,6 +413,15 @@ function displayInlineText(text: string): string { return displayText(text).replaceAll('\n', '\\x0a') } +/** Remove terminal controls from clipboard text before an editable field stores it. */ +function sanitizePastedText(text: string): string { + return text + .replace(TERMINAL_OSC_PATTERN, '') + .replace(TERMINAL_CSI_PATTERN, '') + .replace(TERMINAL_ESCAPE_PATTERN, '') + .replace(TERMINAL_CONTROL_PATTERN, '') +} + /** * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR * attributes, which every terminal remaps to its active color scheme. Body @@ -1335,6 +1349,7 @@ function summarizeResumeCandidate( /** Full-viewport keyboard selector over detached, preflighted resume summaries. */ class ResumePicker implements Component, Focusable { private readonly search = new Input() + private pasteBuffer: string | undefined private selectedIndex = 0 private error = '' focused = false @@ -1360,7 +1375,39 @@ class ResumePicker implements Component, Focusable { || candidate.record.header.id.toLocaleLowerCase().includes(query)) } + private visibleCandidateCount(): number { + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + return Math.min(this.maxVisible, candidateBudget) + } + + private handleBracketedPaste(data: string): boolean { + const start = data.indexOf(BRACKETED_PASTE_START) + if (this.pasteBuffer === undefined && start < 0) return false + if (this.pasteBuffer === undefined) { + const prefix = data.slice(0, start) + if (prefix !== '') this.handleInput(prefix) + this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) + } else { + this.pasteBuffer += data + } + const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) + if (end < 0) return true + const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) + const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) + this.pasteBuffer = undefined + const previous = this.search.getValue() + this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + if (remaining !== '') this.handleInput(remaining) + this.invalidate() + return true + } + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return const filtered = this.filtered() if (matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -1380,11 +1427,11 @@ class ResumePicker implements Component, Focusable { } else if (matchesKey(data, Key.down)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length } else if (matchesKey(data, Key.pageUp)) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible) + this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) } else if (matchesKey(data, Key.pageDown)) { this.selectedIndex = Math.min( Math.max(0, filtered.length - 1), - this.selectedIndex + this.maxVisible, + this.selectedIndex + this.visibleCandidateCount(), ) } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] @@ -1421,7 +1468,7 @@ class ResumePicker implements Component, Focusable { const searchInnerWidth = Math.max(1, contentWidth - 4) lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) - const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, '⌕ ') + const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') lines.push( `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, @@ -1431,8 +1478,7 @@ class ResumePicker implements Component, Focusable { '', ) - const candidateBudget = Math.max(1, Math.floor((height - 13) / 4)) - const visibleCount = Math.min(this.maxVisible, candidateBudget) + const visibleCount = this.visibleCandidateCount() const start = Math.max(0, Math.min( this.selectedIndex - Math.floor(visibleCount / 2), filtered.length - visibleCount, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 127d5c8807..73be9abe76 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -333,6 +333,65 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('sanitizes bracketed-paste terminal controls before storing the search query', async () => { + const target = header('safe-target', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Safe target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[200~Safe\x1b]0;own') + result.terminal.send('ed\x07 target\x1b[31m\x1b[201~') + await tick() + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain('⌕ Safe target') + expect(rendered).not.toContain('owned') + expect(rendered).not.toContain('[31m') + result.terminal.send('\x1b') + result.terminal.send('Safe\x1b[200~\x1b[201~ target') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('⌕ Safe target') + await dispose(result) + }) + + it('pages by the number of candidates that fit the current viewport', async () => { + const targets = Array.from({ length: 8 }, (_, index) => + header(`paged-${index}`, 1000 - index, '/workspace')) + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => targets, + load: async id => ({ + meta: targets.find(target => target.id === id)!, + events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[6~') + await tick() + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain('❯ Paged 3') + result.terminal.send('\x1b[5~') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('❯ Untitled session') + result.terminal.resize(10) + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('⌕') + result.terminal.send('\x03') + await dispose(result) + }) + it('clips candidate count through the configured visible-session limit', async () => { const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')] const result = await setup({ From 6fc92bbb163e7361dc91014c83593c271c960f39 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 16:32:35 +0800 Subject: [PATCH 099/113] fix: ci --- .../ui-conversation/tests/skeleton-branches.spec.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index d1bd50437f..4eb70ea39f 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -3,7 +3,7 @@ // acceptance flows), four-share props form: breadcrumb ancestry derivation + // error strip in ConversationRoot, DetailsPanel non-JSON args / non-text // result blocks / error-only results over the shared store, EmptyState -// failure surface and custom-directory swap with in-component cwd derivation. +// failure surface and path-modal confirm with in-component cwd derivation. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' @@ -271,7 +271,7 @@ describe('EmptyState branches', () => { await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) - it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => { + it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( <EmptyState @@ -291,8 +291,9 @@ describe('EmptyState branches', () => { fireEvent.click(view.getByRole('button', { name: '项目目录' })) fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) - const custom = view.container.querySelector('input')! + const custom = view.getByLabelText('Folder path') fireEvent.change(custom, { target: { value: '/typed/dir' } }) + fireEvent.click(view.getByRole('button', { name: 'Open Folder' })) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'task' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) From f5fe71d34a10125d720e641e5c67b6a51bccfbfc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:36:34 +0800 Subject: [PATCH 100/113] docs(i18n): fix final Agent Note semantics --- .../feature/2026-06-21-subagent-capability-seam.i18n.yaml | 2 +- .../feature/2026-06-21-subagent-capability-seam.zh.md | 4 ++-- ...026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml | 2 +- .../2026-07-12-subagent-persona-tool-filter-and-depth.zh.md | 4 ++-- .../process/2026-07-06-node-engine-floor.i18n.yaml | 2 +- .../implemented/process/2026-07-06-node-engine-floor.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 92cff3d777..3d1140f5cf 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-21-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47 -2026-06-21-subagent-capability-seam.zh.md: 454e5c7859314a1f6706edf690eb79cbcb6cf5ee +2026-06-21-subagent-capability-seam.zh.md: 6294c84a8fa11e492316f4b69048aa5f477aa04f diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 454e5c7859..6294c84a8f 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -42,8 +42,8 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 两类可选能力,两种发现方式 -- **启动时特性**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的特性,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些特性必须在 run 存在之前检查,因此不能是运行时方法。 -- **运行时特性**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。 +- **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 +- **运行时功能**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力,TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。 ### Fork 与 fresh 是独立后端,而非一个 flag diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 3dc9ca062a..ca943e46dc 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -3,4 +3,4 @@ # 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: be1c2c6a389bb7d800bc7a6553da93a5cf912c7f +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 0eb37ef703b6524d036fabd7fe1df69785441290 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index be1c2c6a38..0eb37ef703 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -59,7 +59,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ### 能力门控保持提供方诚实 -能力将请求的特性与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。 +能力将请求的功能与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。 这使 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择不受支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。 @@ -75,7 +75,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的活组合语义,而非不可升级保证。 -安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签均不在本特性范围内。 +安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签均不在本功能范围内。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index cabb6daf05..2cb7f1009d 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd -2026-07-06-node-engine-floor.zh.md: 266ae0fa30729884d5c914631d426393676255a1 +2026-07-06-node-engine-floor.zh.md: 9d376a639378d3a0b9b645aa36c1a5d320d1d147 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index 266ae0fa30..9d376a6393 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -36,4 +36,4 @@ Status: implemented - **开放式 `>=22.19`。** 否决:它宣传支持 Node 23.0–23.5,而在这些版本上 `node:sqlite`(直到 23.4)或类型剥离(直到 23.6)仍需标志。 - **包含 Node 23.6+(`^22.19.0 || >=23.6.0`)。** 否决:23.6+ 确实能无标志运行两个源码特性,但 Node 23 已 end-of-life;宣传一条已终止的发布线会增加一个范围项和一条 CI 分支,而没有任何部署应当使用该运行时。 - **矩阵 `[22, 24, 26]` 而非固定 `22.19`。** 否决:浮动的主版本号条目会随时间上漂,悄然不再验证所声明的 LTS 下限。 -- **将 `@types/node` 保持在下限之前(`^25`)。** 否决:类型定义超前于运行时下限会让仅 Node 24/25 才有的 API 编译通过,仅在 22.x 上运行时才失败。将 `@types/node` 固定在 22.x 线上可将此类问题转化为所有环境下的编译错误。 +- **保持 `@types/node` 超前于运行时下限(`^25`)。** 否决:类型定义超前于运行时下限会让仅 Node 24/25 才有的 API 编译通过,仅在 22.x 上运行时才失败。将 `@types/node` 固定在 22.x 线上可将此类问题转化为所有环境下的编译错误。 From 4be3301952bff43b50838f2a91632b5bc8c6e79b Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 24 Jul 2026 16:56:36 +0800 Subject: [PATCH 101/113] fix: migrate merged-in context/message references to user/message The master merge introduced a tui goal-restore test and the guard parent README that still used the removed context/message event. Point both at the coalesced plugin-sourced user/message. --- packages/guard/README.md | 2 +- packages/ui/tui/tests/tui.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/guard/README.md b/packages/guard/README.md index 05c9625cb0..e7066ba434 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -6,4 +6,4 @@ Behavioral guard plugins that watch the agent loop for unproductive patterns and |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | -Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged `context/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fad0a63b1b..03afab380f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1073,7 +1073,7 @@ describe('pi-tui chat lifecycle and transcript', () => { } const result = await setup({ beforeMount(session) { - session.append('context/message', { + session.append('user/message', { content: renderGoalChange(change), source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, meta: change as unknown as JsonValue, From 6b686aa8320d7ae7c9d0ab4ddd16d0fa49bd6aae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:07:23 +0800 Subject: [PATCH 102/113] docs(i18n): refresh session query snapshot contract --- docs/core-data-structures/session-query.i18n.yaml | 4 ++-- docs/core-data-structures/session-query.zh.md | 12 +++++++++++- scripts/type-equiv.manifest.json | 5 +++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index 4a4a6e31fe..2cba0f9b42 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session-query.md: f91b438bd6448ce5f0d871d595704556262698e8 -session-query.zh.md: 3db50df96c302a6a090fc795085b2e52b70256dd +session-query.md: c6dde8714a0875d46cf7b49cc181daab8f44afe0 +session-query.zh.md: 44be8b7f89e1576e54c8dc3cb60619d6728c6895 diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 3db50df96c..44be8b7f89 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -27,7 +27,17 @@ interface SessionRecord { } ``` -`SessionSurfaceSnapshot` 表示一次精确读取所得的观测,而不是持续保留的订阅。它的原始日志边界与折叠后的事件来自同一次优先使用 live 数据的加载。 +`SessionLogSnapshot` 是供恢复预检使用的完整原始日志:它脱离运行时,并经过回放验证。`SessionSurfaceSnapshot` 表示一次精确读取的 surface 观测结果,而不是持续保留的订阅。 + +```ts type-equiv +/** One validated detached observation of a logical session's complete raw log. */ +interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} +``` ```ts type-equiv /** One atomic live-preferred observation of a session's current model surface. */ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ac5d19e6f3..a15fc21a96 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1512,6 +1512,11 @@ "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.zh.md", + "symbol": "SessionLogSnapshot", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.zh.md", "symbol": "SessionSurfaceSnapshot", From 305930712bcecc81cdfeeefa8cdc7308025cecea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:39:11 +0800 Subject: [PATCH 103/113] docs(i18n): bind authority terminology --- docs/i18n/terminology.md | 2 ++ .../translation-prompt-v4/request-response.expected.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index cc21071cfa..ae28258b9b 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -134,12 +134,14 @@ | mod | 模组 | | | | | model provider | 模型提供方 | | | | | module | 模块 | | | | +| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 | | npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 | | opt-out ratio | opt-out 比例 | | 退出检查比例 | | | orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 | | orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 | | package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 | | pairing | 配对 | | | | +| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 | | peer dependency | 对等依赖 | 对等依赖(peer dependency) | | | | permission | 权限 | | | | | persistence | 持久化 | | | | diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b0ec0a98cd..9aa08c3793 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(Complete translation of the source document)\n</translation>\n\n<review>\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n</review>\n\n<final>\n(Final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write \"无修正\" in `<review>` and copy the translation unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", From 0191a014dd641cb77cc84b5b013934ecf55709c7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:39:22 +0800 Subject: [PATCH 104/113] docs(i18n): preserve authority constraints --- .../architecture/2026-07-08-agent-scope-contexts.i18n.yaml | 2 +- .../architecture/2026-07-08-agent-scope-contexts.zh.md | 2 +- .../2026-07-12-agent-scope-runtime-design.i18n.yaml | 2 +- .../2026-07-12-agent-scope-runtime-design.zh.md | 2 +- ...6-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml | 2 +- .../2026-07-12-subagent-persona-tool-filter-and-depth.zh.md | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 944a3fbc70..67955d377d 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 -2026-07-08-agent-scope-contexts.zh.md: 91ff75309e8327166900772992a16dbd371494fb +2026-07-08-agent-scope-contexts.zh.md: 35e725e43d402b048daf12c3b4be384b3fd2d2ce diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 91ff75309e..35e725e43d 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -142,7 +142,7 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 父级可以拥有一个可见工具比自身更广的子级,因为生命周期所有权不赠予也不限制注册。持有 Cordis 上下文的插件同样运行在同一进程中,可以直接调用可用服务。 -需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父集合授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。 +需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父级子集授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 1f09afec2f..b003d4de31 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-12-agent-scope-runtime-design.md: cf42bbacbfb9d2fcbd72aafba5ea02ddf83e1ce5 -2026-07-12-agent-scope-runtime-design.zh.md: 3d19aa42a7475f01070fbe375b386e782b5f81b7 +2026-07-12-agent-scope-runtime-design.zh.md: d0d599664290dc7dc4debe1ccf8f419a40912ba1 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 3d19aa42a7..d0d5996642 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -389,4 +389,4 @@ Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化 该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 契约的修改,或插件有意在支持的组合 API 之外使用环境服务访问。 -[安全与权限非目标](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升级。 +[安全与权限非目标](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index ca943e46dc..d7dfbedded 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -3,4 +3,4 @@ # 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: 0eb37ef703b6524d036fabd7fe1df69785441290 +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6e9e9ad44fff4dee6ddb286227485420d100f4ee diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index 0eb37ef703..6e9e9ad44f 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -71,9 +71,9 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ## 可见性不是授权 -这些控制组合的是同一可信进程内的行为,而非授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 是其父级的子集,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。 +这些控制组合的是同一可信进程内的行为,而非授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 仅持有父级子集授权,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。 -具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的活组合语义,而非不可升级保证。 +具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的活组合语义,而非不可升权保证。 安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API,以及通用的能力/输出/终止标签均不在本功能范围内。 @@ -93,4 +93,4 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 -代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升级问题。 +代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升权问题。 From ac93d00541e5be646522e7bf69acfab92e404e35 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:07:56 +0800 Subject: [PATCH 105/113] docs(site): publish translated core data pages --- scripts/project-doc-site.spec.ts | 18 +++++++++- website/docs.ts | 57 +++++++++++++++++++------------- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index b9872432a5..e6cc11ae6e 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -178,7 +178,7 @@ describe('docsPages locale routes', () => { const counterpart = byRoute.get(`en/${page.route}`) expect(counterpart, page.route).toBeDefined() expect(counterpart?.locale).toBe('en') - if (page.source.startsWith('docs/user/') || page.route === 'reference/cordis-primer.md') { + if (page.contentLocale === 'zh-CN') { expect(page.source).toMatch(/\.zh\.md$/) expect(page.contentLocale).toBe('zh-CN') expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md')) @@ -190,6 +190,22 @@ describe('docsPages locale routes', () => { } }) + it('projects translated core-data pages while retaining explicit English fallbacks', () => { + const rootPages = docsPages.filter(page => ( + page.locale === 'root' && page.route.startsWith('reference/core-data-structures/') + )) + const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') + const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') + + expect(translated).toHaveLength(18) + expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) + expect(fallbacks.map(page => page.source).sort()).toEqual([ + 'docs/core-data-structures/commands.md', + 'docs/core-data-structures/goal.md', + 'docs/core-data-structures/pty.md', + ]) + }) + it('publishes the Cordis core API under matching locale structures', () => { const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md'] for (const file of files) { diff --git a/website/docs.ts b/website/docs.ts index e033e2d6ef..1888cd908c 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -237,6 +237,35 @@ const cordisPrimerReference = pairedPages([ }, ]) +const coreDataReference = pairedPages(([ + ['core.md', '核心数据结构', 'Core data structures', 0], + ['scope.md', '作用域', 'Scopes', 1], + ['session.md', '会话', 'Sessions', 2], + ['system-prompt.md', '系统提示词', 'System prompts', 4], + ['tools.md', '工具', 'Tools', 5], + ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming', 6], + ['bash.md', 'Bash 执行', 'Bash execution', 7], + ['filesystem.md', '文件系统', 'Filesystem', 9], + ['code-runtime.md', '代码运行时', 'Code runtime', 10], + ['compaction.md', '上下文压缩', 'Compaction', 11], + ['subagent.md', '子代理', 'Subagents', 12], + ['workflow.md', '工作流', 'Workflows', 13], + ['skills.md', '技能', 'Skills', 14], + ['approval.md', '审批', 'Approvals', 15], + ['user-interaction.md', '用户交互', 'User interaction', 16], + ['sandbox.md', '沙箱', 'Sandboxing', 18], + ['web.md', 'Web 访问', 'Web access', 19], + ['persistence.md', '会话持久化', 'Session persistence', 20], +] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ + source: `docs/core-data-structures/${file}`, + route: `reference/core-data-structures/${file}`, + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '数据结构', en: 'Data structures' }, + order, + ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), +}))) + const reference = mirroredPages([ ...([ ['docs/architecture.md', 'reference/index.md', '架构', 'Architecture', 0], @@ -283,28 +312,10 @@ const reference = mirroredPages([ order, })), ...([ - ['core.md', '核心数据结构', 'Core data structures'], - ['scope.md', '作用域', 'Scopes'], - ['session.md', '会话', 'Sessions'], - ['goal.md', '目标', 'Goals'], - ['system-prompt.md', '系统提示词', 'System prompts'], - ['tools.md', '工具', 'Tools'], - ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming'], - ['bash.md', 'Bash 执行', 'Bash execution'], - ['pty.md', 'PTY 会话', 'PTY sessions'], - ['filesystem.md', '文件系统', 'Filesystem'], - ['code-runtime.md', '代码运行时', 'Code runtime'], - ['compaction.md', '上下文压缩', 'Compaction'], - ['subagent.md', '子代理', 'Subagents'], - ['workflow.md', '工作流', 'Workflows'], - ['skills.md', '技能', 'Skills'], - ['approval.md', '审批', 'Approvals'], - ['user-interaction.md', '用户交互', 'User interaction'], - ['commands.md', '命令', 'Human commands'], - ['sandbox.md', '沙箱', 'Sandboxing'], - ['web.md', 'Web 访问', 'Web access'], - ['persistence.md', '会话持久化', 'Session persistence'], - ] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + ['goal.md', '目标', 'Goals', 3], + ['pty.md', 'PTY 会话', 'PTY sessions', 8], + ['commands.md', '命令', 'Human commands', 17], + ] as const).map(([file, rootLabel, enLabel, order]): MirroredPage => ({ source: `docs/core-data-structures/${file}`, route: `reference/core-data-structures/${file}`, contentLocale: 'en-US', @@ -312,7 +323,6 @@ const reference = mirroredPages([ sidebar: { root: 'zh-reference', en: 'en-reference' }, section: { root: '数据结构', en: 'Data structures' }, order, - ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), })), ...([ ['adding-a-package.md', '新增 Package', 'Adding a package'], @@ -336,5 +346,6 @@ export const docsPages: DocsPage[] = [ ...develop, ...cordisTutorial, ...cordisPrimerReference, + ...coreDataReference, ...reference, ] From da3c12e548f0ee1309355382175934884f984a64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:16 +0800 Subject: [PATCH 106/113] docs(i18n): clarify subagent shutdown escalation --- ...2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml | 2 +- .../2026-07-07-claude-code-and-codex-subagent-backends.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml index 8c7cbf5cc2..ecb4e98def 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1 -2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 3d5ccf33b105f9beeca760f82ec849d2ded5ac01 +2026-07-07-claude-code-and-codex-subagent-backends.zh.md: 14e8dde04d9526aaffc0e58be049e13858362887 diff --git a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md index 3d5ccf33b1..14e8dde04d 100644 --- a/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md @@ -22,7 +22,7 @@ subagent seam([seam Agent Note(agent 决策记录)](../../implemented/feat 两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过大声失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。 -**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行升级 CLI 子进程:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。 +**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行逐级加强对 CLI 子进程的终止措施:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}` 和 `agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。 **codex CLI 0.142.5,`codex app-server`(v2 词汇)。** LF 分隔的 JSON,JSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。 From 6865b861d8de7426d5ece1a460eac97e6d64ab94 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:32:31 +0800 Subject: [PATCH 107/113] docs(site): preserve translated core fragment ids --- docs/core-data-structures/core.i18n.yaml | 2 +- docs/core-data-structures/core.zh.md | 6 ++++++ docs/core-data-structures/session.i18n.yaml | 2 +- docs/core-data-structures/session.zh.md | 4 ++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 4cf6d47d23..b352fc37b4 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write core.md: b0aa719974c5e5839027a6f958ce08259053da76 -core.zh.md: 12bb477686ad37cce2e98319b8d75ffefc50bbb9 +core.zh.md: 157fcfb0409fdb63ea50186802a682b5fc476ffc diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 12bb477686..157fcfb040 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -45,6 +45,8 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 > 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务使用生成的[服务目录](../cordis-catalog/services.md)。 +<a id="the-map--derived-union-pattern"></a> + ## `…Map → derived-union` 模式 harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包(package)。 @@ -79,6 +81,8 @@ declare module '@deepseek-ai/dsh-llm' { 消费方最常 `switch` 的两个大型判别联合类型是:**`StreamChunk`**(流式协议)和 **`SessionEvent`**(日志条目)。按仓库约定,对标签做 `switch`——不要链式 `if`——这样每个分支都能窄化类型,拼错的标签会编译失败。 +<a id="branded-ids"></a> + ## 品牌化 ID 跨越包边界的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 @@ -359,6 +363,8 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { 十四种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`context/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +<a id="the-agent-handle"></a> + ## Agent 句柄 `Agent` 是每个插件(UI、钩子、orchestrator)面向编程的 surface。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index ba86de4263..b173cb3506 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write session.md: b342e1c5c3bff030d67c61a6f1daa0c8167182c1 -session.zh.md: 4f539155a1a035a3fb9807c6990ab10b72c7a421 +session.zh.md: 9d221c9bfd7b3e8e60bd964fc0a55b439057e2c6 diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 4f539155a1..9d221c9bfd 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -158,6 +158,8 @@ interface TodoItem { } ``` +<a id="the-request-header-event-requestheader"></a> + ### 请求头事件:`request/header` 请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema + 会话前缀)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 @@ -501,6 +503,8 @@ interface TurnTriggerMap { } ``` +<a id="why-a-turn-ended-turnendreasonmap"></a> + ## 轮次的结束原因:`TurnEndReasonMap` `aborted` 有意作为一种粗粒度的持久结果:它只记录取消中断了实时轮次,不记录是哪个运行时调用方发起取消。仅属于运行时的调用方词汇由 [`AgentCancelCause`](core.md#the-agent-handle) 定义;未来若有审计需求,应新增独立的控制请求事件,而非让终止结果承载这一信息。 From b65421431b97b82ab8f8a2793bb1dc2f29ccda3a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:38:27 +0800 Subject: [PATCH 108/113] docs(i18n): complete Agent Note translation fidelity --- .../2026-06-17-filesystem-capability-seam.i18n.yaml | 2 +- .../2026-06-17-filesystem-capability-seam.zh.md | 8 ++++---- .../2026-06-26-file-context-as-event-gate.i18n.yaml | 2 +- .../2026-06-26-file-context-as-event-gate.zh.md | 6 +++--- .../feature/2026-06-30-subagent-observe-enrich.i18n.yaml | 2 +- .../feature/2026-06-30-subagent-observe-enrich.zh.md | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 28b78d932e..6f85d08fe8 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-17-filesystem-capability-seam.md: 08e8d52b314eb10e2c7ec444dd61a96d8621e032 -2026-06-17-filesystem-capability-seam.zh.md: 8ad3b55b6887a2951211a6756670764bb8e820f5 +2026-06-17-filesystem-capability-seam.zh.md: 6f4889234516ee134c9873781a874b5f1a3644ac diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 8ad3b55b68..6f48892345 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -28,7 +28,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 -读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 Agent Note 确立了由三个包构成的 seam;策略从提供方基类拆出的决策由 [split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [event-gate Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。 +读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 Agent Note 确立了由三个包构成的 seam;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 @@ -73,7 +73,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更;提供它则在后端的原子临界区内守护变更。 - `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以它从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。 -授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 Agent Note 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;split-fs-seam 和 event-gate Agent Note 将其替换为此处描述的基于新鲜度的策略插件。) +授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 Agent Note 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;拆分文件系统 seam 与事件门控两份 Agent Note 将其替换为此处描述的基于新鲜度的策略插件。) 路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。 @@ -137,7 +137,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` - **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 - **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。 -- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 +- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 ## 后果 @@ -149,7 +149,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` **编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`。 -**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 Agent Note 最初将其放在文件系统 seam 内部;split-fs-seam Agent Note 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 +**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 Agent Note 最初将其放在文件系统 seam 内部;拆分文件系统 seam Agent Note 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 **`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测契约不变。 diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index b7c3eddab3..614868ada0 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-06-26-file-context-as-event-gate.md: 4700222aa2e0f91d9f355495c228e2eb92825f55 -2026-06-26-file-context-as-event-gate.zh.md: 5edfda5a2da3a7a06093dae65c89fc5ea49b43a8 +2026-06-26-file-context-as-event-gate.zh.md: 21c8706bcc14790a5092fa59e03bce329049760a diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index 5edfda5a2d..21c8706bcc 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 +[拆分文件系统 seam Agent Note(agent 决策记录)](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 这把三件本应可分离的事情耦合在了一起: @@ -150,7 +150,7 @@ interface Events { ## 取代关系 -本 Agent Note 修正——而非推翻——[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。split-fs-seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 +本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 ## 验证 @@ -158,7 +158,7 @@ interface Events { ## 曾考虑的替代方案 -- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 +- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 - **策略侧版本检查**(`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 - **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。 diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml index b02086964d..c7281e3189 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml @@ -3,4 +3,4 @@ # 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: 4ffa18ccc1cc45283038cb6fb861f5ed09ba090b +2026-06-30-subagent-observe-enrich.zh.md: 578aae0a7273defcc1f88fb2a50c83ef454e3c16 diff --git a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md index 4ffa18ccc1..578aae0a72 100644 --- a/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 曾考虑的替代方案 -**`agentType` subagent 类别标签**(CC 的 `subagent_type` 在 harness 中的对应物),放在请求与两个生命周期载荷上。早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam(此处没有任何逻辑解释它,唯一消费方是 CC 方言桥接层)。CC 桥接层改为直接为其 SubagentStart/Stop 的 `agent_type` matcher 填入 Claude Code 自身的默认值 `"general-purpose"`,因此本 Agent Note 只交付一项丰富化:`lastAssistantMessage`。 +**`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`**:推迟;见下文。 From 51f697db50e99eacaa701d88dc45cbaa59a67640 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:38:31 +0800 Subject: [PATCH 109/113] test(tui): wait for scoped file completion The fake terminal retains earlier autocomplete frames, so the directory follow-up assertion could pass before the scoped lookup completed. Wait for the quoted file mention to be applied before submitting. --- packages/ui/tui/tests/tui.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 880d8fd474..2c4fd21650 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1914,10 +1914,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Folder · docs/') }) result.terminal.send('\t') - await vi.waitFor(() => { - expect(result.terminal.output).toContain('File · design notes.md') - }) + result.terminal.output = '' result.terminal.send('\t') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('@"docs/design notes.md"') + }) await tick() result.terminal.send('\r') await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) From 9e691d54c09173f8074f134008e1eeda7920b491 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:45:23 +0800 Subject: [PATCH 110/113] fix(goal): normalize strict-schema fillers --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 8 ++- .../2026-07-19-model-facing-goal-tools.zh.md | 8 ++- .../headless-agent/tests/headless.snapshot.ts | 7 +- .../tests/snapshots/goal-tools/input.json | 2 +- .../snapshots/goal-tools/replay.override.json | 10 +++ .../goal-tools/stream-json.expected.jsonl | 66 +++++++++++-------- packages/goal/tool-goal/README.md | 4 +- packages/goal/tool-goal/src/index.ts | 20 +++--- .../goal/tool-goal/tests/tool-goal.spec.ts | 21 ++++++ 10 files changed, 101 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index e53c591aa5..70ca07b414 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -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 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7cc3907d70..286329390a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 1a38116035..b0b4fc99ad 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -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 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。 ## 已知限制与延期工作 diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index a1ce4b6cc5..6852fed20b 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -222,7 +222,12 @@ describe('headless stream-json snapshots', () => { const records = parseJsonl(logs[0]?.content ?? '') const calls = records.filter(record => record.type === 'tool/call') .map(record => (record.data as JsonObject | undefined)?.name) - expect(calls).toEqual(['create_goal', 'get_goal']) + expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal']) + const probeResult = records.find(record => record.type === 'tool/result' + && (record.data as JsonObject | undefined)?.callId === 'call_goal_probe') + const probeData = probeResult?.data as JsonObject | undefined + expect(probeData?.isError).toBe(true) + expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND') const goalChanges = records.filter((record) => { if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined diff --git a/examples/headless-agent/tests/snapshots/goal-tools/input.json b/examples/headless-agent/tests/snapshots/goal-tools/input.json index 5263ccd4e2..8449d44c4c 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/input.json +++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json @@ -2,7 +2,7 @@ "steps": [ { "op": "prompt", - "text": "Create a durable goal to finish the snapshot proof, then inspect it." + "text": "Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it." } ] } diff --git a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json index aec5204c7d..c4716ba7b0 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json +++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json @@ -1,4 +1,14 @@ [ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_probe", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_probe", "name": "update_goal", "arguments": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" } }, + { "type": "usage", "usage": { "inputTokens": 15, "outputTokens": 6 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 5005192ec8..16518b2d03 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -1,35 +1,45 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal to","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[1],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_probe","name":"update_goal","argumentsDelta":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15,"outputTokens":6}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true,"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"<goal_state>{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}</goal_state>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":43,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":100,"outputTokens":20}} diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 6e0b25c567..346ac07dcc 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -6,9 +6,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal - `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action. -All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input. All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 96e6389f85..09953041fa 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -132,13 +132,13 @@ function resolveConfig(config: Config): ResolvedConfig { return { blockedAfterConsecutiveRounds: blockedAfter } } -/** Whether an optional string carries a meaningful action-specific value. */ -function hasText(value: string | undefined): boolean { +/** Whether optional text is meaningful rather than a strict-schema empty filler. */ +function hasText(value: string | undefined): value is string { return value !== undefined && value !== '' } -/** Whether an optional round cap carries a meaningful action-specific value. */ -function hasRoundCap(value: number | undefined): boolean { +/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */ +function hasRoundCap(value: number | undefined): value is number { return value !== undefined && value !== 0 } @@ -281,13 +281,11 @@ export function apply(ctx: Context, config: Config): void { const execution = goalToolExecution(ctx, exec) const ref = goalRef(args.goal_id, args.revision) const replacements = { - ...args.objective === undefined ? {} : { objective: args.objective }, - ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + ...hasText(args.objective) ? { objective: args.objective } : {}, + ...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {}, } if (args.action === 'edit') { requireDirectHuman(ctx, execution) - // Some strict-schema providers emit empty placeholders for every declared - // optional field. Reject only values that could change another action. if (hasText(args.blocked_reason)) { throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') } @@ -343,7 +341,11 @@ export function apply(ctx: Context, config: Config): void { presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, 'other', - args.blocked_reason ?? args.objective ?? args.goal_id, + hasText(args.blocked_reason) + ? args.blocked_reason + : hasText(args.objective) + ? args.objective + : hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id, ), })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 8823a6c00a..eaab21e202 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -145,8 +145,17 @@ describe('goal tool registration and presentation', () => { expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.', })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'edit', + objective: 'ship', max_goal_rounds: 0, blocked_reason: '', + })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'edit', + objective: '', max_goal_rounds: 8, blocked_reason: '', + })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 }) expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'resume', + objective: '', max_goal_rounds: 0, blocked_reason: '', })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined() }) @@ -438,11 +447,23 @@ describe('goal tool state transitions', () => { revision: goal.revision, action: 'edit', objective: 'edited', + max_goal_rounds: 0, blocked_reason: '', }, root.agent) expect(resultGoal(edited)).toMatchObject({ objective: 'edited' }) goal = ctx.goals.get(root.agent)! + const capped = await execute(ctx, 'update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'edit', + objective: '', + max_goal_rounds: 8, + blocked_reason: '', + }, root.agent) + expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 }) + goal = ctx.goals.get(root.agent)! + const paused = await execute(ctx, 'update_goal', { goal_id: goal.id, revision: goal.revision, From 158c5e913a46d1884d90c4e08c90e7ba33cb0634 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:27:22 +0800 Subject: [PATCH 111/113] docs(i18n): refresh persistence docs after master merge --- .../architecture/2026-06-14-session-persistence.i18n.yaml | 4 ++-- .../architecture/2026-06-14-session-persistence.zh.md | 8 ++++---- .../2026-07-10-sqlite-session-query-provider.i18n.yaml | 4 ++-- .../2026-07-10-sqlite-session-query-provider.zh.md | 2 +- docs/core-data-structures/persistence.i18n.yaml | 4 ++-- docs/core-data-structures/persistence.zh.md | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 411a4c41a0..8b9aaea118 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-14-session-persistence.md: 1122a52471c6279eff7454cfd31692f05a7bba76 -2026-06-14-session-persistence.zh.md: ecaefb0eb4fc0993ad0792cef9060008b1b3e4fd +2026-06-14-session-persistence.md: 2683bcd68e1f52fbd2bc78660fbe17e556a9a60d +2026-06-14-session-persistence.zh.md: 28e32925a5866fe520c0d9c3eb7b0647845d36f5 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index ecaefb0eb4..28e32925a5 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -21,16 +21,16 @@ Status: implemented - **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。合成的结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 -- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。 -- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) +- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 +- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) - **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并以原样恢复的 id 注册新 agent。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 -上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 +上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 -新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。 +新增两个包(package),以及 `dsh-session` 中的元数据 seam(`session.header`,`create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`([ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。 diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml index 0f6eb2fac9..da1ff70e09 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-sqlite-session-query-provider.md: e622595b7a90d4a49af16b80b3458efd4e877ddb -2026-07-10-sqlite-session-query-provider.zh.md: 55c4a6746ace14c01c2a1ab6025e7453b09e62fe +2026-07-10-sqlite-session-query-provider.md: be8795b609b52eeb03268c4986b52004eef0dba9 +2026-07-10-sqlite-session-query-provider.zh.md: bb3650da907cf86a853f748fa0ee40d5c2168709 diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md index 55c4a6746a..bb3650da90 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.zh.md @@ -38,7 +38,7 @@ Status: implemented 持久化文档在重启后仍然存在。实时会话使用连接本地的 TEMP 表,遮蔽相同 id 的持久化基础行,并在实时所有者分离时重新显露该基础行。关闭数据库会删除实时行。卸载持久化服务会隐藏持久化行,但不会把缺失视为权威删除;重新挂载后,系统会再次观察并对齐后端。实时会话头与持久化会话头的不可变字段发生冲突时,系统会失败,而不会合并两个来源。 -派生 schema 拥有独立的 application id 与单调递增的 schema 版本。系统识别到不兼容版本时,只会重置该派生数据库。如果数据库具有不属于本应用的 application id 或无法识别的用户表,系统会在修改日志模式前拒绝该数据库,防止意外配置的规范会话数据库遭到修改。在 POSIX 文件系统上,缺失的目录与数据库文件会以仅所有者可访问的权限创建,使新的 SQLite 伴随文件沿用该模式;现有权限模式保持不变。一个进程中的一个服务独占一条派生索引路径;代际与实时 TEMP 遮蔽状态都归连接所有,因此不支持跨进程写入方。 +派生 schema 拥有独立的 application id 与单调递增的 schema 版本。持久化与 TEMP 会话元数据均遵循 `SessionHeader.createdAt` 的整数契约,将其存入严格的 `INTEGER` 列。系统识别到不兼容版本时,只会重置该派生数据库。如果数据库具有不属于本应用的 application id 或无法识别的用户表,系统会在修改日志模式前拒绝该数据库,防止意外配置的规范会话数据库遭到修改。在 POSIX 文件系统上,缺失的目录与数据库文件会以仅所有者可访问的权限创建,使新的 SQLite 伴随文件沿用该模式;现有权限模式保持不变。一个进程中的一个服务独占一条派生索引路径;代际与实时 TEMP 遮蔽状态都归连接所有,因此不支持跨进程写入方。 取消会拒绝排队中的操作,并终止调用方对异步源观察的等待;已经中止的观察结果不会提交。Node 的同步 `DatabaseSync` MATCH 调用一旦开始在 JavaScript 线程上执行就无法中断,因此服务会在串行化边界检查信号,但不承诺在语句执行期间抢占。 diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index cb9c2bee69..b7d8fda4e8 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -persistence.md: fdbbc69813211d3b105d19af6d09cfdfc652ec7c -persistence.zh.md: 274a8e0bdafd3e05f7f2be2db0a161bc40b12c5f +persistence.md: dc497fd85f44660c0a981579351b5cfbe0040a4d +persistence.zh.md: 5236f4fe2ba8ad1be7e74bffafebfea19014d7aa diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index 274a8e0bda..5236f4fe2b 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -55,7 +55,7 @@ interface SessionHeader { readonly version: number /** The session's id (mirrors the {@link Session}'s id). */ readonly id: SessionId - /** Unix epoch milliseconds when the session was created. */ + /** Non-negative safe-integer Unix epoch milliseconds when the session was created. */ readonly createdAt: number /** Absolute working directory the session was created in (if any). */ readonly cwd?: string From f9a638b8a6575908feaa59bc0e119d6eb42d3529 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:10:37 +0800 Subject: [PATCH 112/113] Stabilize master CI across platforms --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 6 ++ ...1-serial-cross-platform-ci-reference.zh.md | 6 ++ .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 5 +- .../examples/tui-demo/tests/tui-agent.spec.ts | 3 +- packages/fs/tool-fs/tests/tools.spec.ts | 8 +- packages/host/webserver/README.md | 2 + packages/host/webserver/src/web-plugins.ts | 93 +++++++++++-------- packages/pty/pty-local/README.md | 4 +- packages/pty/pty-local/src/session.ts | 26 ++++-- packages/pty/pty-local/tests/index.spec.ts | 7 +- packages/pty/pty-local/tests/session.spec.ts | 8 +- .../sandbox-policy/tests/policy.spec.ts | 4 +- packages/sandbox/sandbox/tests/roots.spec.ts | 6 +- .../create-sdk/tests/link-workspace.e2e.ts | 20 +++- .../tests/jsonl.spec.ts | 6 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 6 +- .../support/acp-snapshot/src/normalize.ts | 12 ++- packages/support/acp-snapshot/src/suite.ts | 1 + .../acp-snapshot/tests/normalize.spec.ts | 18 ++++ packages/ui/tui/tests/tui.spec.ts | 2 +- scripts/cordis-config-files.spec.ts | 4 +- vitest.config.ts | 6 +- 24 files changed, 173 insertions(+), 86 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index aa0516648b..9b27bd68b6 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-serial-cross-platform-ci-reference.md: b795a0aff62c20967d2c85429c0c6115c1b9585d -2026-07-21-serial-cross-platform-ci-reference.zh.md: 223fd9cf20a1d8228cb0c6b1b2f3f95644becae6 +2026-07-21-serial-cross-platform-ci-reference.md: b041a7412746cbedc0e7f0123c3c0e919094397d +2026-07-21-serial-cross-platform-ci-reference.zh.md: b94337d179cf9ce67b62eecb27a8d8bc7287ee75 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index b795a0aff6..b041a74127 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -18,6 +18,10 @@ Reviewers also need a direct answer to a simpler question: what happens when the Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. +Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures. + +The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling captures its stat baseline synchronously, and PTY readiness retains a prompt candidate until polling confirms that bash owns the foreground process group. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. + Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. @@ -36,4 +40,6 @@ The workflow contains duplicated setup steps and a master reference run can take The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate. +The explicit `pty-local` ownership boundary means Windows does not claim coverage for a backend it cannot load, and forked macOS unit workers cost more process startup time. In return, every supported surface has an honest platform oracle, a native runtime abort cannot erase the rest of the unit result, and timing-sensitive observers start from state established before callers can mutate it. + Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 223fd9cf20..b94337d179 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -18,6 +18,10 @@ Status: implemented 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 +该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。 + +macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑会同步捕获其文件状态基线,PTY 就绪检测则保留提示符候选项,直到轮询确认 bash 已拥有前台进程组。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 + master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 @@ -36,4 +40,6 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。 +明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。 + 移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。 diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index efbe11099f..43ac327d8b 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,4 +1,5 @@ -import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -48,7 +49,7 @@ function seedWorkspace( /** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */ async function seedResumeSession(cwd: string): Promise<void> { - const sessionCwd = await realpath(cwd) + const sessionCwd = realpathSync.native(cwd) const id = SessionId('resume-target') const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd } const events: SessionEvent[] = [ diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index f515253dd2..cdab42289b 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { join } from 'node:path' import type { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' @@ -60,7 +61,7 @@ describe('dsh-tui-demo app', () => { ]) expect(calls[0]?.config).toBeUndefined() expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - expect(calls[4]?.config).toEqual({ path: '/tmp/tui-sessions/session-query.db' }) + expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') }) expect(calls[5]?.config).toEqual({ maxReferences: 2, candidateLimit: 7, diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index bc193cc23a..00fe570215 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, sep } from 'node:path' +import { join, resolve, sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -729,13 +729,13 @@ describe('sandbox escalation surface (write/edit)', () => { it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }]) + expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }]) + expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -768,7 +768,7 @@ describe('sandbox escalation surface (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }]) + expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 9e59699bd6..23f715aa4c 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -8,6 +8,8 @@ Client-disconnect detection hangs off the **response** `close` event, not the re A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection. +In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window retains the last successful baseline and retries when the bundle reappears. + ## Model Experience None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request. diff --git a/packages/host/webserver/src/web-plugins.ts b/packages/host/webserver/src/web-plugins.ts index 9e32cfc012..30afefb8d4 100644 --- a/packages/host/webserver/src/web-plugins.ts +++ b/packages/host/webserver/src/web-plugins.ts @@ -22,8 +22,7 @@ */ import { createHash } from 'node:crypto' -import { readFileSync, unwatchFile, watchFile } from 'node:fs' -import type { Stats } from 'node:fs' +import { readFileSync, statSync, type Stats } from 'node:fs' import { dirname, join } from 'node:path' import type { Context } from 'cordis' @@ -107,9 +106,9 @@ export interface WebPluginRegistryDeps { onError: (err: Error) => void /** * Dev-mode bundle watching: stat-poll every scanned row's client bundle - * (fs.watchFile — polling by design: network mounts deliver no inotify - * events) and re-hash + notify onRebuilt subscribers on change. Absent = - * no watching (prod composition). + * with an explicit stat baseline (polling by design: network mounts deliver + * no inotify events) and re-hash + notify onRebuilt subscribers on change. + * Absent = no watching (prod composition). */ watch?: { /** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */ @@ -217,52 +216,66 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe return rev } - // Dev bundle watch: one fs.watchFile stat poll per table row. A torn read - // of a half-written bundle self-heals — the ongoing write keeps changing - // the stats, so the next poll tick re-hashes the completed file. - const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>() + // Dev bundle watch: capture every row's baseline synchronously before the + // registry is returned, then poll those baselines. fs.watchFile establishes + // its first baseline asynchronously, so an immediate rebuild can otherwise + // become the baseline and disappear without an observed delta. + const watched = new Map<string, { path: string; mtimeMs: number; size: number }>() const syncWatches = (): void => { if (watchInterval === undefined) return for (const [id, watch] of watched) { if (table.get(id)?.clientPath === watch.path) continue - unwatchFile(watch.path, watch.listener) watched.delete(id) } for (const [id, record] of table) { if (watched.has(id)) continue - const listener = (curr: Stats, prev: Stats): void => { - // fs.watchFile fires on any stat delta (atime included); only content - // signals count. An all-zero curr means the file vanished mid-rebuild - // — the completing write fires the next tick, so skipping is safe. - if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return - if (curr.mtimeMs === 0) return - const before = table.get(id)?.entry.rev - let rev: string | undefined - try { - rev = rebuilt(id) - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick - deps.onError(error instanceof Error ? error : new Error(String(error))) - return - } - if (rev === undefined || rev === before) return - for (const notify of rebuildListeners) { - // A throwing subscriber must not escape the fs.watchFile callback - // (that would skip later subscribers and can kill the process). - try { - notify(id, rev) - } catch (error) { - deps.onError(error instanceof Error ? error : new Error(String(error))) - } - } - } - watchFile(record.clientPath, { interval: watchInterval, persistent: false }, listener) - watched.set(id, { path: record.clientPath, listener }) + const baseline = statSync(record.clientPath) + watched.set(id, { path: record.clientPath, mtimeMs: baseline.mtimeMs, size: baseline.size }) } } syncWatches() + const pollWatches = (): void => { + for (const [id, watch] of watched) { + let current: Stats + try { + current = statSync(watch.path) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') continue // mid-rename window; retry against the retained baseline + deps.onError(error instanceof Error ? error : new Error(String(error))) + continue + } + if (current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue + const before = table.get(id)?.entry.rev + let rev: string | undefined + try { + rev = rebuilt(id) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') continue // mid-rename window; retry against the retained baseline + watch.mtimeMs = current.mtimeMs + watch.size = current.size + deps.onError(error instanceof Error ? error : new Error(String(error))) + continue + } + watch.mtimeMs = current.mtimeMs + watch.size = current.size + if (rev === undefined || rev === before) continue + for (const notify of rebuildListeners) { + // A throwing subscriber must not skip later subscribers or escape the + // polling callback into the process event loop. + try { + notify(id, rev) + } catch (error) { + deps.onError(error instanceof Error ? error : new Error(String(error))) + } + } + } + } + const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval) + watchTimer?.unref() + let pending = false const unsubscribe = deps.ctx.on('internal/plugin', () => { if (pending) return @@ -291,7 +304,7 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe }, dispose: () => { unsubscribe() - for (const { path, listener } of watched.values()) unwatchFile(path, listener) + if (watchTimer !== undefined) clearInterval(watchTimer) watched.clear() rebuildListeners.clear() }, diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 2167f91f85..f26d4a3af9 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -1,12 +1,12 @@ # @deepseek-ai/dsh-pty-local -Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child. +Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platform fails as unsupported. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child. ## Plugin (`pty-local`) The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 20cd013dcf..8bb9709bab 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -288,11 +288,12 @@ export class LocalPtySession implements PtyBackendSession { if (sanitized.prompt) { const foregroundPgid = this.inspector.foregroundPgid(this.pid) if (this.shellPgid === undefined) this.shellPgid = foregroundPgid - if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) { - this.promptSeen = true - this.promptTextSeen = sanitized.promptText === true - this.lastOutputAt = Date.now() - } + // Bash can print PROMPT_COMMAND before the kernel publishes its return + // to the foreground process group. Retain the marker; polling below is + // the authority that accepts it only after bash owns the foreground. + this.promptSeen = true + this.promptTextSeen = sanitized.promptText === true + this.lastOutputAt = Date.now() } else if (this.promptSeen && sanitized.promptText === true) { this.promptTextSeen = true } @@ -312,8 +313,11 @@ export class LocalPtySession implements PtyBackendSession { return } if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) { - this.settleActive('stdin_read') - return + const pgid = this.inspector.foregroundPgid(this.pid) + if (this.shellPgid !== undefined && pgid === this.shellPgid) { + this.settleActive('stdin_read') + return + } } const elapsed = Date.now() - operation.startedAt const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 @@ -324,7 +328,13 @@ export class LocalPtySession implements PtyBackendSession { return } } - if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { + // A complete owned marker is stronger evidence than silence, but can race + // the kernel's foreground-PGID handoff. Once it is pending, wait for bash + // ownership (or the absolute timeout) instead of misclassifying that race + // as inferred idle. + if (!(this.promptSeen && this.promptTextSeen) + && startupHasOutput + && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { this.settleActive('inferred_idle') return } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 26f4ddcc11..8fe6d9d4ae 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -187,7 +187,12 @@ describe('LocalPtyBackend startup rollback', () => { kill() { exitListener?.({ exitCode: 0, signal: 15 }) }, resize() {}, clear() {}, pause() {}, resume() {}, } as IPty - const backend = new LocalPtyBackend(ctx, config(), inspector, () => terminal) + const backend = new LocalPtyBackend( + ctx, + config(), + { ...inspector, foregroundPgid: () => terminal.pid }, + () => terminal, + ) const session = await backend.spawn(spec(agent(ctx))) expect(session.motd).toBe('dsh> ') await session.close('test complete') diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 6f7144f409..0faa9d7755 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -286,7 +286,7 @@ describe('LocalPtySession readiness and output', () => { expect(session.motd).toBe('dsh> ') }) - it('trusts prompt markers only while the startup shell owns the foreground group', async () => { + it('retains a prompt marker until the startup shell regains the foreground group', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() const inspector = new FakeInspector() @@ -297,13 +297,13 @@ describe('LocalPtySession readiness and output', () => { let settled = false void operation.done.then(() => { settled = true }) inspector.pgid = 789 - terminal.emitData('\x1b]133;D;0\x07spoofed') - await vi.advanceTimersByTimeAsync(10) + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(60) expect(settled).toBe(false) inspector.pgid = 456 - terminal.emitData('\x1b]133;D;0\x07dsh> ') await vi.advanceTimersByTimeAsync(10) + expect(settled).toBe(true) expect((await operation.done).waitReason).toBe('stdin_read') }) }) diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index cd81caa6b4..9e3eae4605 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -69,7 +69,7 @@ describe('SandboxPolicyService', () => { }) }) - it('resolves a symlink-sensitive session cwd with filesystem semantics', async () => { + it.skipIf(process.platform === 'win32')('resolves a symlink-sensitive session cwd with POSIX component semantics', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-policy-cwd-')) try { const lexical = join(root, 'lexical') @@ -78,7 +78,7 @@ describe('SandboxPolicyService', () => { mkdirSync(lexical) mkdirSync(child, { recursive: true }) const link = join(lexical, 'link') - symlinkSync(child, link, process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(child, link, 'dir') const cwd = `${link}${sep}..` const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts index fd0d2cd7bd..49fdc03816 100644 --- a/packages/sandbox/sandbox/tests/roots.spec.ts +++ b/packages/sandbox/sandbox/tests/roots.spec.ts @@ -14,7 +14,7 @@ import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' describe('canonicalPath', () => { it('resolves symlinks (an existing path realpaths)', () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-')) - expect(canonicalPath(dir)).toBe(realpathSync(dir)) + expect(canonicalPath(dir)).toBe(realpathSync.native(dir)) }) it('returns the spelling as-is when the path cannot be resolved (conservative — matches nothing until it exists)', () => { @@ -30,9 +30,9 @@ describe('writableRoots', () => { it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => { const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-')) const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws }) - expect(roots).toContain(realpathSync(ws)) + expect(roots).toContain(realpathSync.native(ws)) expect(roots).toContain(canonicalPath('/tmp')) - expect(roots).toContain(realpathSync(tmpdir())) + expect(roots).toContain(realpathSync.native(tmpdir())) // Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide). expect(new Set(roots).size).toBe(roots.length) }) diff --git a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts index 6e0b404dc6..0fefefdffa 100644 --- a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts +++ b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' @@ -20,6 +20,15 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const builtScripts = join(repoRoot, 'packages/sdk/scripts/lib/bin.js') const temporary: string[] = [] +function resolveCorepackHome(): string { + return process.env.COREPACK_HOME ?? join( + process.env.XDG_CACHE_HOME + ?? process.env.LOCALAPPDATA + ?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'), + 'node/corepack', + ) +} + afterEach(async () => { await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))) }) @@ -71,13 +80,16 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () } `) const cacheRoot = join(tmpdir(), 'dsh-sdk-link-cache', name) + const pnpmStore = name === 'pnpm' + ? (await execFileAsync(name, ['store', 'path', '--silent'], { encoding: 'utf8' })).stdout.trim() + : undefined const commandEnvironment = { ...scrubEnvironment(), - COREPACK_HOME: join(cacheRoot, 'corepack'), - XDG_CACHE_HOME: join(cacheRoot, 'cache'), + COREPACK_HOME: resolveCorepackHome(), + ...name === 'pnpm' ? {} : { XDG_CACHE_HOME: join(cacheRoot, 'cache') }, XDG_DATA_HOME: join(cacheRoot, 'data'), npm_config_cache: join(cacheRoot, 'npm'), - pnpm_config_store_dir: join(cacheRoot, 'pnpm-store'), + ...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore }, } await execFileAsync(name, manager.installCommand(), { cwd: root, diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 5e9f446379..68bd359b39 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -251,17 +251,15 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { }) it('surfaces non-ENOENT snapshot stat failures after discovery', async () => { - const blocker = join(root, 'snapshot-not-a-directory') - await writeFile(blocker, 'x') const persistence = ctx.sessionPersistence as unknown as { listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>> } const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{ header: meta('snapshot-stat-failure'), - path: join(blocker, 'session.jsonl'), + path: `${root}\0snapshot-stat-failure`, }]) - await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/ENOTDIR/) + await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/null bytes/) discovery.mockRestore() }) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 05a4974c19..8fea461545 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -6,7 +6,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 2821969457..5f6154d321 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -17,7 +17,7 @@ */ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' -import { existsSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' @@ -141,6 +141,8 @@ export interface RunResult { sessionId?: string /** The generated cwd the session ran in (the bash workspace). */ cwd: string + /** Filesystem-resolved spellings of {@link cwd} that child processes may report. */ + cwdAliases: string[] /** * Every persisted session log harvested after the run, ordered primary-first: * the top-level (parent) session — the one with no `parentSession` — then each @@ -222,6 +224,7 @@ export function snapshotSpillRoot( */ export async function runScenario(input: InputScript, opts: RunOptions): Promise<RunResult> { const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), 'acp-snap-cwd-')) + const cwdAliases = [...new Set([realpathSync(cwd), realpathSync.native(cwd)])] const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) // Fixed path length: spill-policy budgets the preview against the REAL path // before stdout normalization, so tmpdir() length differences churn expected outputs. @@ -329,6 +332,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise rawStdout: launched.rawStdout(), stderr: launched.stderr(), cwd, + cwdAliases, ...sessionId !== undefined ? { sessionId } : {}, sessionLogs, } diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index b32c5c0574..577dddceb6 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -46,6 +46,8 @@ export interface NormalizeContext { sessionIds: string[] /** The generated cwd the run used — replaced with `{{cwd}}`. */ cwd: string + /** Other filesystem spellings of the same cwd (for example Windows short and long paths). */ + cwdAliases?: readonly string[] } /** How cwd-rooted path separators are represented after the cwd is tokenized. */ @@ -60,9 +62,13 @@ export interface NormalizeOptions { /** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathMode): string { let out = value - // cwd first (longest, most specific), then explicit session ids, then any - // residual UUID (covers ids that appear in places we didn't enumerate). - out = out.split(ctx.cwd).join(CWD) + // Filesystem APIs can report one directory with several spellings. Replace + // every known spelling longest-first so a shorter alias cannot corrupt a + // longer one before it is tokenized. + const cwdSpellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])] + .filter(spelling => spelling.length > 0) + .sort((left, right) => right.length - left.length) + for (const spelling of cwdSpellings) out = out.split(spelling).join(CWD) out = out.split(`/private${CWD}`).join(CWD) if (cwdPathMode === 'canonical') { // Restrict separator conversion to paths rooted at the cwd token. A global diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 51e958a106..7f0190bb2c 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -629,6 +629,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...result.sessionLogs.map(l => l.id), ], cwd: result.cwd, + cwdAliases: result.cwdAliases, } // Record writes live model fixtures; keyless refresh writes every comparable replayed diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index bdd85491d2..e9a1983c76 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -44,6 +44,24 @@ describe('normalizeStdout', () => { expect(out).not.toContain(ctx.sessionIds[0] as string) }) + it('scrubs every filesystem spelling of the cwd longest-first', () => { + const longCwd = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snapshot` + const aliasedCtx: NormalizeContext = { + sessionIds: [], + cwd: String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snapshot`, + cwdAliases: [ + longCwd, + String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp`, + ], + } + const raw = JSON.stringify({ + cwd: longCwd, + path: `${longCwd}\\nested\\proof.txt`, + }) + const frame = JSON.parse(normalizeStdout(raw, aliasedCtx)) as { cwd: string; path: string } + expect(frame).toEqual({ cwd: '{{cwd}}', path: '{{cwd}}/nested/proof.txt' }) + }) + it('canonicalizes only cwd-rooted path separators', () => { const windowsCtx: NormalizeContext = { sessionIds: [], diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 2c4fd21650..0fea8b2750 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1877,7 +1877,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await mkdir(join(cwd, 'docs'), { recursive: true }) await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n') await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n') - await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n') + await writeFile(join(cwd, 'unsafe\u007ffile.ts'), 'unsafe name\n') const result = await setup({ cwd, tools: { diff --git a/scripts/cordis-config-files.spec.ts b/scripts/cordis-config-files.spec.ts index 49da2c6aaf..ae5c4b1580 100644 --- a/scripts/cordis-config-files.spec.ts +++ b/scripts/cordis-config-files.spec.ts @@ -29,8 +29,8 @@ describe('cordisConfigFiles', () => { } expect(cordisConfigFiles(root)).toEqual([ - 'examples/agent.cordis.yaml', - 'examples/headless.cordis.yml', + join('examples', 'agent.cordis.yaml'), + join('examples', 'headless.cordis.yml'), ]) }) }) diff --git a/vitest.config.ts b/vitest.config.ts index 4040439035..23341aaedd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ const windowsUnsupportedPackages = process.platform === 'win32' ? [ 'packages/bash/*', 'packages/hooks/*', + 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', 'packages/sdk/create-sdk', 'packages/sdk/helper', @@ -59,7 +60,10 @@ export default defineConfig({ plugins: [pathsPlugin()], test: { name: 'thread-safe', - pool: 'threads', + // Node 24 has aborted in its CJS lexer from a macOS arm64 worker + // thread. A fork contains that external runtime failure to the test + // process; other hosts retain the lower-overhead thread pool. + pool: process.platform === 'darwin' ? 'forks' : 'threads', setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ From 1d066e0f745523860cc93607c1140d76ddb382ca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:33:33 +0800 Subject: [PATCH 113/113] Fix remaining PTY and bundle watch races Keep inherited child prompt markers bounded by the normal silence fallback. Stage web-plugin rescans atomically and retain missing watch state until a successful rebuild. --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 2 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/src/web-plugins.ts | 68 ++++++++++----- .../host/webserver/tests/web-plugins.spec.ts | 86 ++++++++++++++++++- packages/pty/pty-local/README.md | 2 +- packages/pty/pty-local/src/session.ts | 12 ++- packages/pty/pty-local/tests/session.spec.ts | 17 +++- 9 files changed, 158 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 9b27bd68b6..1a6a99d648 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-serial-cross-platform-ci-reference.md: b041a7412746cbedc0e7f0123c3c0e919094397d -2026-07-21-serial-cross-platform-ci-reference.zh.md: b94337d179cf9ce67b62eecb27a8d8bc7287ee75 +2026-07-21-serial-cross-platform-ci-reference.md: 3c0ae200d7dbd5b04eae6db2d6628dccc72103bf +2026-07-21-serial-cross-platform-ci-reference.zh.md: 5c159e12739d68e0baed72aaa08331072e2c3601 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index b041a74127..3c0ae200d7 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -20,7 +20,7 @@ Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GAT Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures. -The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling captures its stat baseline synchronously, and PTY readiness retains a prompt candidate until polling confirms that bash owns the foreground process group. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. +The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index b94337d179..5c159e1273 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -20,7 +20,7 @@ Status: implemented 该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。 -macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑会同步捕获其文件状态基线,PTY 就绪检测则保留提示符候选项,直到轮询确认 bash 已拥有前台进程组。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 +macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 23f715aa4c..baa57dbfe1 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -8,7 +8,7 @@ Client-disconnect detection hangs off the **response** `close` event, not the re A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection. -In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window retains the last successful baseline and retries when the bundle reappears. +In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. ## Model Experience diff --git a/packages/host/webserver/src/web-plugins.ts b/packages/host/webserver/src/web-plugins.ts index 30afefb8d4..4e95e7a091 100644 --- a/packages/host/webserver/src/web-plugins.ts +++ b/packages/host/webserver/src/web-plugins.ts @@ -129,6 +129,13 @@ interface WebPluginRecord { clientPath: string } +interface WatchedBundle { + path: string + mtimeMs: number + size: number + dirty: boolean +} + /** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */ function parseDshClient(name: string, value: unknown): DshClientDeclaration | undefined { if (value === undefined) return undefined @@ -203,8 +210,32 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`) } + const stageWatches = ( + candidateTable: Map<string, WebPluginRecord>, + currentWatches: Map<string, WatchedBundle>, + ): Map<string, WatchedBundle> => { + const candidateWatches = new Map<string, WatchedBundle>() + if (watchInterval === undefined) return candidateWatches + for (const [id, record] of candidateTable) { + const current = currentWatches.get(id) + if (current?.path === record.clientPath) { + candidateWatches.set(id, { ...current }) + continue + } + const baseline = statSync(record.clientPath) + candidateWatches.set(id, { + path: record.clientPath, + mtimeMs: baseline.mtimeMs, + size: baseline.size, + dirty: false, + }) + } + return candidateWatches + } + let table = scan(deps) let graph = composeGraph(table) + let watched = stageWatches(table, new Map()) const rebuildListeners = new Set<(id: string, rev: string) => void>() const rebuilt = (id: string): string | undefined => { @@ -220,21 +251,6 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe // registry is returned, then poll those baselines. fs.watchFile establishes // its first baseline asynchronously, so an immediate rebuild can otherwise // become the baseline and disappear without an observed delta. - const watched = new Map<string, { path: string; mtimeMs: number; size: number }>() - const syncWatches = (): void => { - if (watchInterval === undefined) return - for (const [id, watch] of watched) { - if (table.get(id)?.clientPath === watch.path) continue - watched.delete(id) - } - for (const [id, record] of table) { - if (watched.has(id)) continue - const baseline = statSync(record.clientPath) - watched.set(id, { path: record.clientPath, mtimeMs: baseline.mtimeMs, size: baseline.size }) - } - } - syncWatches() - const pollWatches = (): void => { for (const [id, watch] of watched) { let current: Stats @@ -242,18 +258,24 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe current = statSync(watch.path) } catch (error) { const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT') continue // mid-rename window; retry against the retained baseline + if (code === 'ENOENT') { + watch.dirty = true + continue + } deps.onError(error instanceof Error ? error : new Error(String(error))) continue } - if (current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue + if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue const before = table.get(id)?.entry.rev let rev: string | undefined try { rev = rebuilt(id) } catch (error) { const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT') continue // mid-rename window; retry against the retained baseline + if (code === 'ENOENT') { + watch.dirty = true + continue + } watch.mtimeMs = current.mtimeMs watch.size = current.size deps.onError(error instanceof Error ? error : new Error(String(error))) @@ -261,6 +283,7 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe } watch.mtimeMs = current.mtimeMs watch.size = current.size + watch.dirty = false if (rev === undefined || rev === before) continue for (const notify of rebuildListeners) { // A throwing subscriber must not skip later subscribers or escape the @@ -283,9 +306,12 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe queueMicrotask(() => { pending = false try { - table = scan(deps) - graph = composeGraph(table) - syncWatches() + const candidateTable = scan(deps) + const candidateGraph = composeGraph(candidateTable) + const candidateWatches = stageWatches(candidateTable, watched) + table = candidateTable + graph = candidateGraph + watched = candidateWatches } catch (error) { // Keep serving the previous graph: a mid-flight rescan failure must not // take down the boot manifest for plugins that were fine. diff --git a/packages/host/webserver/tests/web-plugins.spec.ts b/packages/host/webserver/tests/web-plugins.spec.ts index b9efb1c5c9..0acff4d78b 100644 --- a/packages/host/webserver/tests/web-plugins.spec.ts +++ b/packages/host/webserver/tests/web-plugins.spec.ts @@ -1,11 +1,41 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + statSync, + type PathLike, + type Stats, + unlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts' import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts' +const fsControl = vi.hoisted(() => ({ failNextStatPath: undefined as string | undefined })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs')>() + return { + ...actual, + statSync: (path: PathLike): Stats => { + if (String(path) === fsControl.failNextStatPath) { + fsControl.failNextStatPath = undefined + throw Object.assign(new Error('staged bundle missing'), { code: 'ENOENT' }) + } + return actual.statSync(path) + }, + } +}) + +afterEach(() => { + fsControl.failNextStatPath = undefined + vi.useRealTimers() +}) + /** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */ function makePkg(root: string, name: string, pkg: Record<string, unknown>, withBundle = true): string { const dir = join(root, name.replaceAll('/', '__')) @@ -147,6 +177,58 @@ describe('createHostWebPluginRegistry', () => { expect(rebuilds).toHaveLength(1) }) + it('watch mode: a failed rescan baseline preserves the published table and graph', async () => { + const { deps, entries, errors, ctx, root } = makeDeps([ + { name: 'stable', pkg: webDecl() }, + { name: 'late', pkg: webDecl(), loaded: false }, + ]) + deps.watch = { intervalMs: 1_000 } + const registry = createHostWebPluginRegistry(deps) + const before = registry.graph() + + ;(entries[1] as { fiber?: unknown }).fiber = {} + fsControl.failNextStatPath = join(root, 'late', 'lib', 'client.js') + ctx.emit('internal/plugin', ctx.fiber) + await Promise.resolve() + + expect(errors[0]?.message).toContain('staged bundle missing') + expect(registry.graph()).toBe(before) + expect(registry.clientPath('late')).toBeUndefined() + + ctx.emit('internal/plugin', ctx.fiber) + await Promise.resolve() + expect(registry.graph().entries.map(row => row.id)).toEqual(['stable', 'late']) + registry.dispose() + }) + + it('watch mode: a missing bundle forces a re-hash when identical metadata reappears', async () => { + vi.useFakeTimers() + const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }]) + const bundle = join(root, 'watched', 'lib', 'client.js') + const fixedTime = new Date(1_600_000_000_000) + utimesSync(bundle, fixedTime, fixedTime) + deps.watch = { intervalMs: 20 } + const registry = createHostWebPluginRegistry(deps) + const baseline = statSync(bundle) + const rebuilds: { id: string; rev: string }[] = [] + registry.onRebuilt((id, rev) => rebuilds.push({ id, rev })) + + unlinkSync(bundle) + await vi.advanceTimersByTimeAsync(20) + writeFileSync(bundle, 'x'.repeat(baseline.size)) + utimesSync(bundle, fixedTime, fixedTime) + const restored = statSync(bundle) + expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({ + mtimeMs: baseline.mtimeMs, + size: baseline.size, + }) + await vi.advanceTimersByTimeAsync(20) + + expect(rebuilds).toHaveLength(1) + expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev) + registry.dispose() + }) + it('rejects a non-positive or non-integer watch interval at build time', () => { for (const intervalMs of [0, -5, 1.5]) { const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }]) diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index f26d4a3af9..a2b3aec391 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -6,7 +6,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable or the ordinary silence bound expires. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 8bb9709bab..4b1faccc94 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -328,13 +328,11 @@ export class LocalPtySession implements PtyBackendSession { return } } - // A complete owned marker is stronger evidence than silence, but can race - // the kernel's foreground-PGID handoff. Once it is pending, wait for bash - // ownership (or the absolute timeout) instead of misclassifying that race - // as inferred idle. - if (!(this.promptSeen && this.promptTextSeen) - && startupHasOutput - && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { + // A prompt candidate can race bash's foreground handoff, but an interactive + // child also inherits PROMPT_COMMAND. Silence therefore remains the bound + // on waiting for shell ownership instead of letting a child marker suppress + // readiness until the absolute timeout. + if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { this.settleActive('inferred_idle') return } diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 0faa9d7755..4be21c79a6 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -298,7 +298,7 @@ describe('LocalPtySession readiness and output', () => { void operation.done.then(() => { settled = true }) inspector.pgid = 789 terminal.emitData('\x1b]133;D;0\x07dsh> ') - await vi.advanceTimersByTimeAsync(60) + await vi.advanceTimersByTimeAsync(40) expect(settled).toBe(false) inspector.pgid = 456 @@ -306,6 +306,21 @@ describe('LocalPtySession readiness and output', () => { expect(settled).toBe(true) expect((await operation.done).waitReason).toBe('stdin_read') }) + + it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = new LocalPtySession(terminal.asPty(), inspector, config()) + await initialize(session, terminal) + + const operation = session.startSend({ text: 'bash -i', submit: true }) + inspector.pgid = 789 + terminal.emitData('\x1b]133;D;0\x07child> ') + await vi.advanceTimersByTimeAsync(100) + + expect((await operation.done).waitReason).toBe('inferred_idle') + }) }) describe('LocalPtySession bounds, signals, and teardown', () => {